├── .prettierrc ├── .dockerignore ├── tsconfig.build.json ├── nest-cli.json ├── src ├── app.controller.ts ├── auth │ ├── decorators │ │ └── public.decorator.ts │ ├── role │ │ ├── role.decorator.ts │ │ └── role.guard.ts │ ├── guards │ │ ├── local-auth.guard.ts │ │ └── jwt-auth.guard.ts │ ├── sign-message.repository.ts │ ├── auth.type.ts │ ├── dto │ │ ├── get-sign-message.dto.ts │ │ └── login-user.dto.ts │ ├── auth.service.spec.ts │ ├── auth.controller.spec.ts │ ├── sign-message.subscriber.ts │ ├── strategies │ │ ├── local.strategy.ts │ │ └── jwt.strategy.ts │ ├── auth.module.ts │ ├── sign-message.entity.ts │ ├── auth.controller.ts │ └── auth.service.ts ├── config │ ├── app.config.ts │ ├── file.config.ts │ ├── redis.config.ts │ ├── psbt.config.ts │ ├── jwt.config.ts │ ├── datasource.ts │ └── database.config.ts ├── file │ ├── file.type.ts │ ├── file.module.ts │ ├── file.service.spec.ts │ ├── file.controller.spec.ts │ ├── file.service.ts │ └── file.controller.ts ├── psbt │ ├── psbt.module.ts │ ├── psbt.service.spec.ts │ └── psbt.service.ts ├── inscription │ ├── inscription.type.ts │ ├── inscription.repository.ts │ ├── inscription.module.ts │ ├── inscription.service.spec.ts │ ├── inscription.controller.spec.ts │ ├── inscription.subscriber.ts │ ├── inscription.entity.ts │ ├── inscription.controller.ts │ └── inscription.service.ts ├── user │ ├── user.type.ts │ ├── user.repository.ts │ ├── user.service.spec.ts │ ├── user.controller.spec.ts │ ├── user.module.ts │ ├── user.subscriber.ts │ ├── validator │ │ ├── user-exists-by-uuid.validator.ts │ │ └── user-exists-by-address.validator.ts │ ├── dto │ │ ├── get-top-sellers-page.dto.ts │ │ └── update-user.dto.ts │ ├── user.controller.ts │ ├── user.service.ts │ └── user.entity.ts ├── search │ ├── search.type.ts │ ├── dto │ │ └── search.dto.ts │ ├── search.service.spec.ts │ ├── search.controller.spec.ts │ ├── search.module.ts │ ├── search.controller.ts │ └── search.service.ts ├── collection │ ├── colletion.repository.ts │ ├── dto │ │ ├── get-popular-collection.dto.ts │ │ └── create-collection.dto.ts │ ├── collection.service.spec.ts │ ├── collection.controller.spec.ts │ ├── collection.subscriber.ts │ ├── collection.module.ts │ ├── collection.controller.ts │ ├── collection.entity.ts │ ├── collection.type.ts │ └── collection.service.ts ├── swap-offer │ ├── swap-offer.repository.ts │ ├── buyer-swap-inscription.repository.ts │ ├── dto │ │ ├── cancel-swap-offer.dto.ts │ │ ├── get-offer.dto.ts │ │ ├── get-user-history.dto.ts │ │ ├── seller-sign-psbt.dto.ts │ │ ├── buyer-sign-psbt.dto.ts │ │ └── generate-swap-psbt.dto.ts │ ├── seller-swap-inscription.repository.ts │ ├── swap-offer.type.ts │ ├── swap-offer.service.spec.ts │ ├── swap-offer.controller.spec.ts │ ├── swap-offer.subscriber.ts │ ├── buyer-swap-inscription.subscriber.ts │ ├── seller-swap-inscription.subscriber.ts │ ├── swap-offer.module.ts │ ├── buyer-swap-inscription.entity.ts │ ├── seller-swap-inscription.entity.ts │ ├── swap-offer.entity.ts │ ├── swap-offer.controller.ts │ └── swap-offer.service.ts ├── common │ ├── helpers │ │ ├── env.helper.ts │ │ └── api-response.helper.ts │ ├── interceptors │ │ └── cookie-to-body.interceptor.ts │ ├── filters │ │ └── internal-server-error-exceptions.filter.ts │ ├── pagination │ │ ├── cursor-filter.dto.ts │ │ └── pagination.types.ts │ └── validators │ │ └── env.validator.ts ├── main.ts ├── app-bootstrap.manager.ts └── app.module.ts ├── test ├── jest-e2e.json └── app.e2e-spec.ts ├── docker-compose.yml ├── .gitignore ├── .env.example ├── .eslintrc.js ├── tsconfig.json ├── .db ├── Dockerfile ├── package.json └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | dist 6 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | @Controller() 4 | export class AppController { 5 | constructor() {} 6 | } 7 | -------------------------------------------------------------------------------- /src/auth/decorators/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const Public = () => SetMetadata('isPublic', true); 4 | -------------------------------------------------------------------------------- /src/auth/role/role.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const Role = (...args: string[]) => SetMetadata('role', args); 4 | -------------------------------------------------------------------------------- /src/config/app.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('appConfig', () => ({ 4 | environment: process.env.NODE_ENV, 5 | })); 6 | -------------------------------------------------------------------------------- /src/file/file.type.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class UploadImage { 4 | @ApiProperty({ description: `Uploaded image url` }) 5 | imageUrl: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/psbt/psbt.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { PsbtService } from './psbt.service'; 4 | 5 | @Module({ 6 | providers: [PsbtService], 7 | }) 8 | export class PsbtModule {} 9 | -------------------------------------------------------------------------------- /src/inscription/inscription.type.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class InscriptionInfo { 4 | @ApiProperty({ description: `Inscription id` }) 5 | inscriptionId: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/auth/guards/local-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class LocalAuthGuard extends AuthGuard('local') {} 6 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/config/file.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('fileConfig', () => ({ 4 | s3AccessKey: process.env.AWS_S3_ACCESS_KEY, 5 | awsS3KeySecret: process.env.AWS_S3_KEY_SECRET, 6 | awsS3Bucket: process.env.AWS_S3_BUCKET, 7 | })); 8 | -------------------------------------------------------------------------------- /src/file/file.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { FileController } from './file.controller'; 4 | import { FileService } from './file.service'; 5 | 6 | @Module({ 7 | controllers: [FileController], 8 | providers: [FileService] 9 | }) 10 | export class FileModule {} 11 | -------------------------------------------------------------------------------- /src/user/user.type.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class BtcPrice { 4 | @ApiProperty({ description: `bitcoin price` }) 5 | price: number; 6 | } 7 | 8 | export class TotalSales { 9 | @ApiProperty({ description: `total sales` }) 10 | totalSales: number; 11 | } 12 | -------------------------------------------------------------------------------- /src/config/redis.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import path = require('path'); 3 | 4 | export default registerAs('redisConfig', () => ({ 5 | host: process.env.REDIS_HOST, 6 | port: process.env.REDIS_PORT, 7 | username: process.env.REDIS_USERNAME, 8 | password: process.env.REDIS_PASSWORD, 9 | })); 10 | -------------------------------------------------------------------------------- /src/config/psbt.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('psbtConfig', () => ({ 4 | feePercent: Number(process.env.FEE_PERCENT), 5 | adminAddress: process.env.ADMIN_WALLET_ADDRESS, 6 | network: process.env.NETWORK, 7 | unisatApiKey: process.env.UNISAT_KEY, 8 | bisApiKey: process.env.BIS_KEY, 9 | })); 10 | -------------------------------------------------------------------------------- /src/user/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | 4 | import { User } from './user.entity'; 5 | 6 | @Injectable() 7 | export class UserRepository extends Repository { 8 | constructor(private readonly dataSource: DataSource) { 9 | super(User, dataSource.manager); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/search/search.type.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class SearchResult { 4 | @ApiProperty({ description: `Inscription search result` }) 5 | inscriptions: any[]; 6 | 7 | @ApiProperty({ description: `Collection search result` }) 8 | collections: any[]; 9 | 10 | @ApiProperty({ description: `User search result` }) 11 | users: any[]; 12 | } 13 | -------------------------------------------------------------------------------- /src/collection/colletion.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | 4 | import { Collection } from './collection.entity'; 5 | 6 | @Injectable() 7 | export class CollectionRepository extends Repository { 8 | constructor(private readonly dataSource: DataSource) { 9 | super(Collection, dataSource.manager); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/swap-offer/swap-offer.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | 4 | import { SwapOffer } from './swap-offer.entity'; 5 | 6 | @Injectable() 7 | export class SwapOfferRepository extends Repository { 8 | constructor(private readonly dataSource: DataSource) { 9 | super(SwapOffer, dataSource.manager); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/auth/sign-message.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | 4 | import { SignMessage } from './sign-message.entity'; 5 | 6 | @Injectable() 7 | export class SignMessageRepository extends Repository { 8 | constructor(private readonly dataSource: DataSource) { 9 | super(SignMessage, dataSource.manager); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/inscription/inscription.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | 4 | import { Inscription } from './inscription.entity'; 5 | 6 | @Injectable() 7 | export class InscriptionRepository extends Repository { 8 | constructor(private readonly dataSource: DataSource) { 9 | super(Inscription, dataSource.manager); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/search/dto/search.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, MaxLength, MinLength } from 'class-validator'; 3 | 4 | export class SearchDto { 5 | @ApiProperty({ 6 | example: 'block', 7 | required: true, 8 | minimum: 1, 9 | maximum: 128, 10 | description: 'Inscription Id', 11 | }) 12 | @IsString() 13 | @MinLength(1) 14 | @MaxLength(255) 15 | keyword: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/swap-offer/buyer-swap-inscription.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { BuyerSwapInscription } from './buyer-swap-inscription.entity'; 4 | 5 | @Injectable() 6 | export class BuyerSwapInscriptionRepository extends Repository { 7 | constructor(private readonly dataSource: DataSource) { 8 | super(BuyerSwapInscription, dataSource.manager); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/swap-offer/dto/cancel-swap-offer.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, MaxLength, MinLength } from 'class-validator'; 3 | 4 | export class CancelSwapOfferDto { 5 | @ApiProperty({ 6 | example: '2bea0983-f47f-4166-9477-42dc3ceeb45f', 7 | required: false, 8 | minimum: 1, 9 | maximum: 40, 10 | description: 'uuid', 11 | }) 12 | @IsString() 13 | @MinLength(1) 14 | @MaxLength(40) 15 | uuid: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/swap-offer/seller-swap-inscription.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | 4 | import { SellerSwapInscription } from './seller-swap-inscription.entity'; 5 | 6 | @Injectable() 7 | export class SellerSwapInscriptionRepository extends Repository { 8 | constructor(private readonly dataSource: DataSource) { 9 | super(SellerSwapInscription, dataSource.manager); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/collection/dto/get-popular-collection.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsInt, IsOptional, Min } from 'class-validator'; 4 | 5 | export class GetPopularCollectionDto { 6 | @ApiProperty({ 7 | example: '7', 8 | required: false, 9 | minimum: 1, 10 | maximum: 128, 11 | description: 'Time', 12 | }) 13 | @Type(() => Number) 14 | @IsInt() 15 | @IsOptional() 16 | time: number = 1; 17 | } 18 | -------------------------------------------------------------------------------- /src/auth/auth.type.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { Role } from '@src/user/user.entity'; 4 | 5 | export interface AccessTokenInterface { 6 | uuid: string; 7 | address: string; 8 | role: Role; 9 | } 10 | 11 | export class AccessToken { 12 | @ApiProperty({ description: `Access Token` }) 13 | accessToken: string; 14 | } 15 | 16 | export class GenerateMessage { 17 | @ApiProperty({ description: `message for bip322 signature` }) 18 | message: string; 19 | } 20 | -------------------------------------------------------------------------------- /src/auth/dto/get-sign-message.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, MaxLength, MinLength } from 'class-validator'; 3 | 4 | export class GetSignMessageDto { 5 | @ApiProperty({ 6 | example: 'tb1pn952y2hrpzf9gfnmsg0zht2smhn2lrzxz569vtpt23aj8wqgndmsc4g58d', 7 | required: true, 8 | minimum: 1, 9 | maximum: 128, 10 | description: 'Wallet Address', 11 | }) 12 | @IsString() 13 | @MinLength(1) 14 | @MaxLength(128) 15 | address: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/common/helpers/env.helper.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from '../validators/env.validator'; 2 | 3 | export class EnvHelper { 4 | static verifyNodeEnv(): void { 5 | if (process.env.NODE_ENV === undefined) { 6 | process.env.NODE_ENV = 'development'; 7 | } 8 | } 9 | 10 | static getEnvFilePath(): string { 11 | return `.env.${process.env.NODE_ENV?.toLowerCase()}`; 12 | } 13 | 14 | static isProduction(): boolean { 15 | return process.env.NODE_ENV === Environment.Production; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/auth/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecutionContext, 3 | Injectable, 4 | UnauthorizedException, 5 | } from '@nestjs/common'; 6 | import { AuthGuard } from '@nestjs/passport'; 7 | 8 | @Injectable() 9 | export class JwtAuthGuard extends AuthGuard('jwt') { 10 | handleRequest( 11 | err: any, 12 | user: any, 13 | info: any, 14 | context: ExecutionContext, 15 | status?: any, 16 | ) { 17 | if (user) { 18 | return user; 19 | } 20 | 21 | throw new UnauthorizedException(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # development postgres database 2 | services: 3 | db: 4 | image: postgres:15-alpine 5 | environment: 6 | POSTGRES_USER: munch 7 | POSTGRES_PASSWORD: munch 8 | POSTGRES_DB: munch 9 | ports: 10 | - '127.0.0.1:5432:5432' 11 | volumes: 12 | - db:/var/lib/postgresql/data 13 | redis: 14 | container_name: cache 15 | image: redis 16 | ports: 17 | - 6379:6379 18 | volumes: 19 | - redis:/data 20 | 21 | volumes: 22 | db: 23 | redis: 24 | driver: local 25 | -------------------------------------------------------------------------------- /src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from './auth.service'; 3 | 4 | describe('AuthService', () => { 5 | let service: AuthService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AuthService], 10 | }).compile(); 11 | 12 | service = module.get(AuthService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/file/file.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { FileService } from './file.service'; 3 | 4 | describe('FileService', () => { 5 | let service: FileService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [FileService], 10 | }).compile(); 11 | 12 | service = module.get(FileService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/psbt/psbt.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PsbtService } from './psbt.service'; 3 | 4 | describe('PsbtService', () => { 5 | let service: PsbtService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [PsbtService], 10 | }).compile(); 11 | 12 | service = module.get(PsbtService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserService } from './user.service'; 3 | 4 | describe('UserService', () => { 5 | let service: UserService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [UserService], 10 | }).compile(); 11 | 12 | service = module.get(UserService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/swap-offer/swap-offer.type.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class GeneratePbst { 4 | @ApiProperty({ description: `Generated psbt` }) 5 | psbt: string; 6 | 7 | @ApiProperty({ description: `Input count of psbt` }) 8 | inputCount: number; 9 | } 10 | 11 | export class SignPsbtResult { 12 | @ApiProperty({ description: `Sign pbst result` }) 13 | msg: string; 14 | } 15 | 16 | export class PushTxResult extends SignPsbtResult { 17 | @ApiProperty({ description: `Transaction id` }) 18 | txId: string; 19 | } 20 | -------------------------------------------------------------------------------- /src/search/search.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SearchService } from './search.service'; 3 | 4 | describe('SearchService', () => { 5 | let service: SearchService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [SearchService], 10 | }).compile(); 11 | 12 | service = module.get(SearchService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | 4 | describe('AuthController', () => { 5 | let controller: AuthController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [AuthController], 10 | }).compile(); 11 | 12 | controller = module.get(AuthController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/file/file.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { FileController } from './file.controller'; 3 | 4 | describe('FileController', () => { 5 | let controller: FileController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [FileController], 10 | }).compile(); 11 | 12 | controller = module.get(FileController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/user/user.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserController } from './user.controller'; 3 | 4 | describe('UserController', () => { 5 | let controller: UserController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [UserController], 10 | }).compile(); 11 | 12 | controller = module.get(UserController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/swap-offer/swap-offer.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SwapOfferService } from './swap-offer.service'; 3 | 4 | describe('SwapOfferService', () => { 5 | let service: SwapOfferService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [SwapOfferService], 10 | }).compile(); 11 | 12 | service = module.get(SwapOfferService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | # env 38 | .env* 39 | !.env.example -------------------------------------------------------------------------------- /src/collection/collection.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CollectionService } from './collection.service'; 3 | 4 | describe('CollectionService', () => { 5 | let service: CollectionService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [CollectionService], 10 | }).compile(); 11 | 12 | service = module.get(CollectionService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/inscription/inscription.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { UserModule } from '@src/user/user.module'; 4 | import { InscriptionService } from './inscription.service'; 5 | import { InscriptionRepository } from './inscription.repository'; 6 | import { InscriptionController } from './inscription.controller'; 7 | 8 | @Module({ 9 | imports: [UserModule], 10 | providers: [InscriptionService, InscriptionRepository], 11 | exports: [InscriptionService, InscriptionRepository], 12 | controllers: [InscriptionController], 13 | }) 14 | export class InscriptionModule {} 15 | -------------------------------------------------------------------------------- /src/search/search.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SearchController } from './search.controller'; 3 | 4 | describe('SearchController', () => { 5 | let controller: SearchController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [SearchController], 10 | }).compile(); 11 | 12 | controller = module.get(SearchController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/inscription/inscription.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { InscriptionService } from './inscription.service'; 3 | 4 | describe('InscriptionService', () => { 5 | let service: InscriptionService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [InscriptionService], 10 | }).compile(); 11 | 12 | service = module.get(InscriptionService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/swap-offer/swap-offer.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SwapOfferController } from './swap-offer.controller'; 3 | 4 | describe('SwapOfferController', () => { 5 | let controller: SwapOfferController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [SwapOfferController], 10 | }).compile(); 11 | 12 | controller = module.get(SwapOfferController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/collection/collection.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CollectionController } from './collection.controller'; 3 | 4 | describe('CollectionController', () => { 5 | let controller: CollectionController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [CollectionController], 10 | }).compile(); 11 | 12 | controller = module.get(CollectionController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/swap-offer/dto/get-offer.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | IsIn, 4 | IsOptional, 5 | IsString, 6 | MaxLength, 7 | MinLength, 8 | } from 'class-validator'; 9 | 10 | import { PageOptionsDto } from '@src/common/pagination/pagination.types'; 11 | 12 | export class GetOfferDto extends PageOptionsDto { 13 | @ApiProperty({ 14 | example: 'block', 15 | required: false, 16 | minimum: 1, 17 | maximum: 128, 18 | description: 'Inscription Id or Address', 19 | }) 20 | @IsString() 21 | @MinLength(1) 22 | @MaxLength(255) 23 | @IsOptional() 24 | keyword?: string; 25 | } 26 | -------------------------------------------------------------------------------- /src/inscription/inscription.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { InscriptionController } from './inscription.controller'; 3 | 4 | describe('InscriptionController', () => { 5 | let controller: InscriptionController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [InscriptionController], 10 | }).compile(); 11 | 12 | controller = module.get(InscriptionController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | 3 | import { AuthModule } from '@src/auth/auth.module'; 4 | import { UserExistsByAddressValidator } from './validator/user-exists-by-address.validator'; 5 | import { UserController } from './user.controller'; 6 | import { UserRepository } from './user.repository'; 7 | import { UserService } from './user.service'; 8 | 9 | @Module({ 10 | imports: [forwardRef(() => AuthModule)], 11 | controllers: [UserController], 12 | providers: [UserService, UserRepository, UserExistsByAddressValidator], 13 | exports: [UserService, UserRepository], 14 | }) 15 | export class UserModule {} 16 | -------------------------------------------------------------------------------- /src/config/jwt.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('jwtConfig', () => ({ 4 | secret: process.env.JWT_SECRET, 5 | refreshTokenCookieDomain: process.env.JWT_REFRESH_TOKEN_COOKIE_DOMAIN, 6 | refreshTokenCookieSecure: 7 | process.env.JWT_REFRESH_TOKEN_COOKIE_SECURE === 'true', 8 | refreshTokenCookieHttpOnly: 9 | process.env.JWT_REFRESH_TOKEN_COOKIE_HTTPONLY === 'true', 10 | refreshTokenDurationDays: process.env.JWT_REFRESH_TOKEN_DURATION_DAYS, 11 | refreshTokenMaxSessions: process.env.JWT_REFRESH_TOKEN_MAX_SESSIONS, 12 | accessTokenDurationMinutes: process.env.JWT_ACCESS_TOKEN_DURATION_MINUTES, 13 | })); 14 | -------------------------------------------------------------------------------- /src/user/user.subscriber.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EntitySubscriberInterface, 3 | EventSubscriber, 4 | InsertEvent, 5 | UpdateEvent, 6 | } from 'typeorm'; 7 | import { v4 as uuid } from 'uuid'; 8 | 9 | import { User } from './user.entity'; 10 | 11 | @EventSubscriber() 12 | export class UserSubscriber implements EntitySubscriberInterface { 13 | listenTo(): any { 14 | return User; 15 | } 16 | 17 | beforeInsert(event: InsertEvent): void | Promise { 18 | if (!event.entity.uuid) { 19 | event.entity.uuid = uuid(); 20 | } 21 | } 22 | 23 | beforeUpdate(event: UpdateEvent): void { 24 | event.entity.updatedAt = new Date(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/user/validator/user-exists-by-uuid.validator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ValidatorConstraint, 3 | ValidatorConstraintInterface, 4 | } from 'class-validator'; 5 | 6 | import { UserService } from '../user.service'; 7 | 8 | @ValidatorConstraint({ name: 'userExistsByUuidValidator', async: true }) 9 | export class UserExistsByUuidValidator implements ValidatorConstraintInterface { 10 | constructor(private readonly userService: UserService) {} 11 | 12 | async validate(uuid: string): Promise { 13 | const user = await this.userService.findByUuid(uuid); 14 | 15 | return Boolean(user); 16 | } 17 | 18 | defaultMessage() { 19 | return 'User not found'; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TYPEORM_HOST= 2 | TYPEORM_PORT= 3 | TYPEORM_PASSWORD= 4 | TYPEORM_DATABASE= 5 | TYPEORM_USERNAME= 6 | TYPEORM_CONNECTION= 7 | TYPEORM_MIGRATIONS= 8 | TYPEORM_MIGRATIONS_DIR= 9 | TYPEORM_LOGGING= 10 | TYPEORM_POOL_SIZE= 11 | POSTGRESQL_TLS= 12 | 13 | JWT_SECRET= 14 | JWT_REFRESH_TOKEN_COOKIE_DOMAIN= 15 | JWT_REFRESH_TOKEN_DURATION_DAYS= 16 | JWT_REFRESH_TOKEN_MAX_SESSIONS= 17 | JWT_ACCESS_TOKEN_DURATION_MINUTES= 18 | JWT_REFRESH_TOKEN_COOKIE_SECURE= 19 | JWT_REFRESH_TOKEN_COOKIE_HTTPONLY= 20 | 21 | SWAGGER_SERVER_URL= 22 | 23 | 24 | FEE_PERCENT=1 25 | ADMIN_WALLET_ADDRESS= 26 | NETWORK= 27 | 28 | AWS_S3_ACCESS_KEY= 29 | AWS_S3_KEY_SECRET= 30 | AWS_S3_BUCKET= 31 | 32 | BIS_KEY= -------------------------------------------------------------------------------- /src/swap-offer/dto/get-user-history.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | IsIn, 4 | IsOptional, 5 | IsString, 6 | MaxLength, 7 | MinLength, 8 | } from 'class-validator'; 9 | 10 | import { GetOfferDto } from './get-offer.dto'; 11 | import { OfferStatus } from '../swap-offer.entity'; 12 | 13 | export class GetUserHistoryDto extends GetOfferDto { 14 | @ApiProperty({ 15 | example: 'pushed', 16 | required: false, 17 | minimum: 1, 18 | maximum: 128, 19 | description: 'Status of offer', 20 | }) 21 | @IsString() 22 | @IsIn(['canceled', 'pending', 'pushed', 'expired', 'failed']) 23 | @IsOptional() 24 | status?: OfferStatus; 25 | } 26 | -------------------------------------------------------------------------------- /src/search/search.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { CollectionModule } from '@src/collection/collection.module'; 4 | import { UserModule } from '@src/user/user.module'; 5 | import { InscriptionModule } from '@src/inscription/inscription.module'; 6 | import { SearchService } from './search.service'; 7 | import { SearchController } from './search.controller'; 8 | import { PsbtModule } from '@src/psbt/psbt.module'; 9 | import { PsbtService } from '@src/psbt/psbt.service'; 10 | 11 | @Module({ 12 | imports: [InscriptionModule, CollectionModule, UserModule, PsbtModule], 13 | providers: [SearchService, PsbtService], 14 | controllers: [SearchController], 15 | }) 16 | export class SearchModule {} 17 | -------------------------------------------------------------------------------- /src/swap-offer/swap-offer.subscriber.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EntitySubscriberInterface, 3 | EventSubscriber, 4 | InsertEvent, 5 | UpdateEvent, 6 | } from 'typeorm'; 7 | import { v4 as uuid } from 'uuid'; 8 | 9 | import { SwapOffer } from './swap-offer.entity'; 10 | 11 | @EventSubscriber() 12 | export class SwapOfferSubscriber 13 | implements EntitySubscriberInterface 14 | { 15 | listenTo(): any { 16 | return SwapOffer; 17 | } 18 | 19 | beforeInsert(event: InsertEvent): void | Promise { 20 | if (!event.entity.uuid) { 21 | event.entity.uuid = uuid(); 22 | } 23 | } 24 | 25 | beforeUpdate(event: UpdateEvent): void { 26 | event.entity.updatedAt = new Date(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/collection/collection.subscriber.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EntitySubscriberInterface, 3 | EventSubscriber, 4 | InsertEvent, 5 | UpdateEvent, 6 | } from 'typeorm'; 7 | import { v4 as uuid } from 'uuid'; 8 | 9 | import { Collection } from './collection.entity'; 10 | 11 | @EventSubscriber() 12 | export class CollectionSubscriber 13 | implements EntitySubscriberInterface 14 | { 15 | listenTo(): any { 16 | return Collection; 17 | } 18 | 19 | beforeInsert(event: InsertEvent): void | Promise { 20 | if (!event.entity.uuid) { 21 | event.entity.uuid = uuid(); 22 | } 23 | } 24 | 25 | beforeUpdate(event: UpdateEvent): void { 26 | event.entity.updatedAt = new Date(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/auth/sign-message.subscriber.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EntitySubscriberInterface, 3 | EventSubscriber, 4 | InsertEvent, 5 | UpdateEvent, 6 | } from 'typeorm'; 7 | import { v4 as uuid } from 'uuid'; 8 | 9 | import { SignMessage } from './sign-message.entity'; 10 | 11 | @EventSubscriber() 12 | export class SignMessageSubscriber 13 | implements EntitySubscriberInterface 14 | { 15 | listenTo(): any { 16 | return SignMessage; 17 | } 18 | 19 | beforeInsert(event: InsertEvent): void | Promise { 20 | if (!event.entity.uuid) { 21 | event.entity.uuid = uuid(); 22 | } 23 | } 24 | 25 | beforeUpdate(event: UpdateEvent): void { 26 | event.entity.updatedAt = new Date(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/auth/strategies/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from 'passport-local'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 4 | import { AuthService } from '../auth.service'; 5 | 6 | @Injectable() 7 | export class LocalStrategy extends PassportStrategy(Strategy) { 8 | constructor(private authService: AuthService) { 9 | super({ usernameField: 'email' }); 10 | } 11 | 12 | async validate(email: string, password: string): Promise { 13 | // const user = await this.authService.validateUser(email); 14 | // if (!user) { 15 | // throw new UnauthorizedException(); 16 | // } 17 | // return user; 18 | 19 | return true; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/inscription/inscription.subscriber.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EntitySubscriberInterface, 3 | EventSubscriber, 4 | InsertEvent, 5 | UpdateEvent, 6 | } from 'typeorm'; 7 | import { v4 as uuid } from 'uuid'; 8 | 9 | import { Inscription } from './inscription.entity'; 10 | 11 | @EventSubscriber() 12 | export class InscriptionSubscriber 13 | implements EntitySubscriberInterface 14 | { 15 | listenTo(): any { 16 | return Inscription; 17 | } 18 | 19 | beforeInsert(event: InsertEvent): void | Promise { 20 | if (!event.entity.uuid) { 21 | event.entity.uuid = uuid(); 22 | } 23 | } 24 | 25 | beforeUpdate(event: UpdateEvent): void { 26 | event.entity.updatedAt = new Date(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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": "es2017", 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 | "paths": { 21 | "@src/*": ["src/*"], 22 | "@test/*": ["test/*"] 23 | } 24 | }, 25 | "ts-node": { 26 | "require": ["tsconfig-paths/register"] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/swap-offer/buyer-swap-inscription.subscriber.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EntitySubscriberInterface, 3 | EventSubscriber, 4 | InsertEvent, 5 | UpdateEvent, 6 | } from 'typeorm'; 7 | import { v4 as uuid } from 'uuid'; 8 | 9 | import { BuyerSwapInscription } from './buyer-swap-inscription.entity'; 10 | 11 | @EventSubscriber() 12 | export class BuyerSwapInscriptionSubscriber 13 | implements EntitySubscriberInterface 14 | { 15 | listenTo(): any { 16 | return BuyerSwapInscription; 17 | } 18 | 19 | beforeInsert(event: InsertEvent): void | Promise { 20 | if (!event.entity.uuid) { 21 | event.entity.uuid = uuid(); 22 | } 23 | } 24 | 25 | beforeUpdate(event: UpdateEvent): void { 26 | event.entity.updatedAt = new Date(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/swap-offer/seller-swap-inscription.subscriber.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EntitySubscriberInterface, 3 | EventSubscriber, 4 | InsertEvent, 5 | UpdateEvent, 6 | } from 'typeorm'; 7 | import { v4 as uuid } from 'uuid'; 8 | import { SellerSwapInscription } from './seller-swap-inscription.entity'; 9 | 10 | @EventSubscriber() 11 | export class SellerSwapInscriptionSubscriber 12 | implements EntitySubscriberInterface 13 | { 14 | listenTo(): any { 15 | return SellerSwapInscription; 16 | } 17 | 18 | beforeInsert(event: InsertEvent): void | Promise { 19 | if (!event.entity.uuid) { 20 | event.entity.uuid = uuid(); 21 | } 22 | } 23 | 24 | beforeUpdate(event: UpdateEvent): void { 25 | event.entity.updatedAt = new Date(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/user/validator/user-exists-by-address.validator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ValidationArguments, 3 | ValidatorConstraint, 4 | ValidatorConstraintInterface, 5 | } from 'class-validator'; 6 | 7 | import { UserService } from '../user.service'; 8 | 9 | @ValidatorConstraint({ name: 'userExistsByAddressValidator', async: true }) 10 | export class UserExistsByAddressValidator 11 | implements ValidatorConstraintInterface 12 | { 13 | constructor(private readonly userService: UserService) {} 14 | 15 | async validate(address: string, args: ValidationArguments): Promise { 16 | const userExists = await this.userService.findByAddress(address); 17 | 18 | return !Boolean(userExists); 19 | } 20 | 21 | defaultMessage(args: ValidationArguments) { 22 | return `User with address '${args.value}' already exists`; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 3 | import { AppBootstrapManager } from './app-bootstrap.manager'; 4 | import { AppModule } from './app.module'; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule); 8 | 9 | const config = new DocumentBuilder() 10 | .setTitle('Blockmucher API') 11 | .setDescription('The Blockmucher API documentation') 12 | .setVersion('1.0') 13 | .addBearerAuth() 14 | .addServer(process.env.SWAGGER_SERVER_URL) 15 | .build(); 16 | 17 | const document = SwaggerModule.createDocument(app, config); 18 | SwaggerModule.setup('docs', app, document); 19 | 20 | AppBootstrapManager.setAppDefaults(app); 21 | 22 | await app.listen(process.env.PORT || 3005); 23 | } 24 | bootstrap(); 25 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { User } from '@src/user/user.entity'; 6 | 7 | @Injectable() 8 | export class JwtStrategy extends PassportStrategy(Strategy) { 9 | constructor(private readonly configService: ConfigService) { 10 | super({ 11 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 12 | ignoreExpiration: false, 13 | secretOrKey: configService.get('jwtConfig.secret'), 14 | }); 15 | } 16 | 17 | async validate(payload: Partial) { 18 | return { 19 | uuid: payload.uuid, 20 | address: payload.address, 21 | role: payload.role, 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/collection/collection.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { InscriptionModule } from '@src/inscription/inscription.module'; 4 | import { InscriptionService } from '@src/inscription/inscription.service'; 5 | import { UserModule } from '@src/user/user.module'; 6 | import { CollectionController } from './collection.controller'; 7 | import { CollectionService } from './collection.service'; 8 | import { CollectionRepository } from './colletion.repository'; 9 | import { PsbtModule } from '@src/psbt/psbt.module'; 10 | import { PsbtService } from '@src/psbt/psbt.service'; 11 | 12 | @Module({ 13 | imports: [InscriptionModule, UserModule, PsbtModule], 14 | controllers: [CollectionController], 15 | providers: [CollectionService, CollectionRepository, InscriptionService, PsbtService], 16 | exports: [CollectionService], 17 | }) 18 | export class CollectionModule {} 19 | -------------------------------------------------------------------------------- /src/auth/role/role.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { Observable } from 'rxjs'; 4 | 5 | @Injectable() 6 | export class RoleGuard implements CanActivate { 7 | constructor(private reflector: Reflector) {} 8 | 9 | matchRoles(roles: string[], userRole: string) { 10 | return roles.some((role) => role === userRole); 11 | } 12 | 13 | canActivate( 14 | context: ExecutionContext, 15 | ): boolean | Promise | Observable { 16 | const role = this.reflector.get('role', context.getHandler()); 17 | if (!role) { 18 | return true; 19 | } 20 | const request = context.switchToHttp().getRequest(); 21 | const user = request.user; 22 | if (user.isRegistered === false) return false; 23 | return this.matchRoles(role, user.role); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/user/dto/get-top-sellers-page.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { 4 | IsInt, 5 | IsOptional, 6 | IsString, 7 | MaxLength, 8 | MinLength, 9 | } from 'class-validator'; 10 | 11 | import { PageOptionsDto } from '@src/common/pagination/pagination.types'; 12 | 13 | export class GetTopSellersPageDto extends PageOptionsDto { 14 | @ApiProperty({ 15 | example: '7', 16 | required: false, 17 | minimum: 1, 18 | maximum: 128, 19 | description: 'Time', 20 | }) 21 | @Type(() => Number) 22 | @IsInt() 23 | @IsOptional() 24 | time: number = 7; 25 | 26 | @ApiProperty({ 27 | example: 'block', 28 | required: true, 29 | minimum: 1, 30 | maximum: 128, 31 | description: 'user name', 32 | }) 33 | @IsString() 34 | @MinLength(1) 35 | @MaxLength(255) 36 | @IsOptional() 37 | keyword?: string; 38 | } 39 | -------------------------------------------------------------------------------- /src/common/interceptors/cookie-to-body.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | } from '@nestjs/common'; 7 | import { Observable } from 'rxjs'; 8 | import { Request } from 'express'; 9 | 10 | @Injectable() 11 | export class CookieToBodyInterceptor implements NestInterceptor { 12 | public cookieName: string; 13 | public bodyAttributeName: string; 14 | 15 | constructor(cookieName: string, bodyAttributeName: string) { 16 | this.cookieName = cookieName; 17 | this.bodyAttributeName = bodyAttributeName; 18 | } 19 | 20 | intercept(context: ExecutionContext, next: CallHandler): Observable { 21 | const request: Request = context.switchToHttp().getRequest(); 22 | 23 | if (request?.cookies && this.cookieName in request.cookies) { 24 | request.body[this.bodyAttributeName] = request.cookies[this.cookieName]; 25 | } 26 | 27 | return next.handle().pipe(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/common/filters/internal-server-error-exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | ArgumentsHost, 5 | InternalServerErrorException, 6 | } from '@nestjs/common'; 7 | import * as Sentry from '@sentry/node'; 8 | 9 | @Catch(InternalServerErrorException) 10 | export class InternalServerErrorExceptionsFilter implements ExceptionFilter { 11 | async catch(exception: any, host: ArgumentsHost): Promise { 12 | const ctx = host.switchToHttp(); 13 | const response = ctx.getResponse(); 14 | const request = ctx.getRequest(); 15 | const statusCode = exception.getStatus(); 16 | 17 | Sentry.setContext('request', { 18 | url: request.url, 19 | body: request.body, 20 | query: request.query, 21 | params: request.params, 22 | language: request.language, 23 | headers: request.headers, 24 | rawHeaders: request.rawHeaders, 25 | }); 26 | Sentry.captureException(exception); 27 | 28 | response.status(statusCode).json({ 29 | statusCode, 30 | message: 'Internal server error', 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/file/file.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import * as AWS from 'aws-sdk'; 4 | import * as path from 'path'; 5 | 6 | @Injectable() 7 | export class FileService { 8 | private s3: AWS.S3; 9 | 10 | constructor(private readonly configService: ConfigService) { 11 | this.s3 = new AWS.S3({ 12 | region: 'us-east-2', 13 | accessKeyId: this.configService.get('fileConfig.s3AccessKey'), 14 | secretAccessKey: this.configService.get('fileConfig.awsS3KeySecret'), 15 | }); 16 | } 17 | 18 | async upload(image: Express.Multer.File): Promise { 19 | const { originalname } = image; 20 | const fileExtName = path.extname(originalname); 21 | const fileName = `${Date.now().toString()}${fileExtName}`; 22 | 23 | const params = { 24 | Bucket: process.env.AWS_S3_BUCKET, 25 | Key: fileName, 26 | Body: image.buffer, 27 | ACL: 'public-read', 28 | }; 29 | 30 | const { Location } = await this.s3.upload(params).promise(); 31 | 32 | return Location; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/swap-offer/dto/seller-sign-psbt.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEnum, IsString, MaxLength, MinLength } from 'class-validator'; 3 | 4 | import { WalletTypes } from '@src/user/user.entity'; 5 | 6 | export class SellerSignPsbtDto { 7 | @ApiProperty({ 8 | example: 9 | 'e19eea22dc6f6fc16c5a6aad4f6c7bdfe16733def97be0f6cb1c5d12ede37dbfi0', 10 | required: false, 11 | minimum: 1, 12 | maximum: 5000, 13 | description: 'Unsigned Psbt', 14 | }) 15 | @IsString() 16 | @MinLength(1) 17 | @MaxLength(5000) 18 | psbt: string; 19 | 20 | @ApiProperty({ 21 | example: 22 | '032d5536574e87d1e394219d536e8e2087cfa0de3787e49820315bdcb291ef6723', 23 | required: false, 24 | minimum: 1, 25 | maximum: 5000, 26 | description: 'Seller signed Psbt', 27 | }) 28 | @IsString() 29 | @MinLength(1) 30 | @MaxLength(5000) 31 | signedPsbt: string; 32 | 33 | @ApiProperty({ 34 | example: 'Unisat', 35 | required: false, 36 | minimum: 1, 37 | maximum: 128, 38 | description: 'Seller wallet type', 39 | }) 40 | @IsEnum(WalletTypes) 41 | walletType: WalletTypes; 42 | } 43 | -------------------------------------------------------------------------------- /src/common/pagination/cursor-filter.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsBase64, IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; 4 | 5 | export class CursorFilterDto { 6 | @ApiProperty({ required: false, description: 'Paginates results forward' }) 7 | @IsBase64() 8 | @IsOptional() 9 | afterCursor?: string; 10 | 11 | @ApiProperty({ required: false, description: 'Paginates results backward' }) 12 | @IsBase64() 13 | @IsOptional() 14 | beforeCursor?: string; 15 | 16 | @ApiProperty({ example: 10, minimum: 1, maximum: 50, required: false }) 17 | @Type(() => Number) 18 | @IsInt() 19 | @Min(1) 20 | @Max(25) 21 | @IsOptional() 22 | limit = 5; 23 | 24 | @ApiProperty({ example: 'id', enum: ['id'], required: false }) 25 | @IsOptional() 26 | @IsIn(['id']) 27 | orderParam = 'id'; 28 | 29 | @ApiProperty({ example: 'ASC', enum: ['ASC', 'DESC'], required: false }) 30 | @IsOptional() 31 | @IsIn(['ASC', 'DESC']) 32 | orderType: 'ASC' | 'DESC' = 'DESC'; 33 | 34 | constructor(partial?: Partial) { 35 | Object.assign(this, partial); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/swap-offer/dto/buyer-sign-psbt.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | IsEnum, 4 | IsOptional, 5 | IsString, 6 | MaxLength, 7 | MinLength, 8 | } from 'class-validator'; 9 | 10 | import { WalletTypes } from '@src/user/user.entity'; 11 | 12 | export class BuyerSignPsbtDto { 13 | @ApiProperty({ 14 | example: 15 | 'e19eea22dc6f6fc16c5a6aad4f6c7bdfe16733def97be0f6cb1c5d12ede37dbfi0', 16 | required: false, 17 | minimum: 1, 18 | maximum: 5000, 19 | description: 'Unsigned Psbt', 20 | }) 21 | @IsString() 22 | @MinLength(1) 23 | @MaxLength(5000) 24 | psbt: string; 25 | 26 | @ApiProperty({ 27 | example: 28 | '032d5536574e87d1e394219d536e8e2087cfa0de3787e49820315bdcb291ef6723', 29 | required: false, 30 | minimum: 1, 31 | maximum: 5000, 32 | description: 'Buyer signed Psbt', 33 | }) 34 | @IsString() 35 | @MinLength(1) 36 | @MaxLength(5000) 37 | signedPsbt: string; 38 | 39 | @ApiProperty({ 40 | example: 'Unisat', 41 | required: false, 42 | minimum: 1, 43 | maximum: 128, 44 | description: 'Buyer wallet type', 45 | }) 46 | @IsEnum(WalletTypes) 47 | walletType: WalletTypes; 48 | } 49 | -------------------------------------------------------------------------------- /src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | forwardRef, 4 | Inject, 5 | Get, 6 | UseGuards, 7 | HttpStatus, 8 | Request, 9 | } from '@nestjs/common'; 10 | 11 | import { AuthService } from '@src/auth/auth.service'; 12 | import { UserService } from './user.service'; 13 | import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; 14 | import { JwtAuthGuard } from '@src/auth/guards/jwt-auth.guard'; 15 | import { ApiResponseHelper } from '@src/common/helpers/api-response.helper'; 16 | import { User } from './user.entity'; 17 | 18 | @Controller('user') 19 | export class UserController { 20 | constructor( 21 | private readonly userService: UserService, 22 | @Inject(forwardRef(() => AuthService)) 23 | private readonly authService: AuthService, 24 | ) {} 25 | 26 | @ApiBearerAuth() 27 | @UseGuards(JwtAuthGuard) 28 | @ApiOperation({ description: `Get user info`, tags: ['User'] }) 29 | @ApiResponse(ApiResponseHelper.success(User, HttpStatus.OK)) 30 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 31 | @Get('/user-info') 32 | async getUserPushedOffers(@Request() req) { 33 | return this.userService.findByAddress(req.user.address); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | import { PassportModule } from '@nestjs/passport'; 5 | 6 | import { UserModule } from '@src/user/user.module'; 7 | import { AuthController } from './auth.controller'; 8 | import { AuthService } from './auth.service'; 9 | import { JwtStrategy } from './strategies/jwt.strategy'; 10 | import { LocalStrategy } from './strategies/local.strategy'; 11 | import { SignMessageRepository } from './sign-message.repository'; 12 | 13 | @Module({ 14 | imports: [ 15 | forwardRef(() => UserModule), 16 | PassportModule, 17 | JwtModule.registerAsync({ 18 | imports: [ConfigModule], 19 | inject: [ConfigService], 20 | useFactory: async (configService: ConfigService) => { 21 | return { 22 | secret: configService.get('jwtConfig.secret'), 23 | signOptions: { expiresIn: '24h' }, 24 | }; 25 | }, 26 | }), 27 | ], 28 | controllers: [AuthController], 29 | providers: [AuthService, LocalStrategy, JwtStrategy, SignMessageRepository], 30 | exports: [AuthService], 31 | }) 32 | export class AuthModule {} 33 | -------------------------------------------------------------------------------- /src/swap-offer/swap-offer.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { InscriptionModule } from '@src/inscription/inscription.module'; 4 | import { InscriptionService } from '@src/inscription/inscription.service'; 5 | import { UserModule } from '@src/user/user.module'; 6 | import { PsbtModule } from '@src/psbt/psbt.module'; 7 | import { PsbtService } from '@src/psbt/psbt.service'; 8 | import { SwapOfferService } from './swap-offer.service'; 9 | import { SwapOfferController } from './swap-offer.controller'; 10 | import { SwapOfferRepository } from './swap-offer.repository'; 11 | import { BuyerSwapInscription } from './buyer-swap-inscription.entity'; 12 | import { BuyerSwapInscriptionRepository } from './buyer-swap-inscription.repository'; 13 | import { SellerSwapInscriptionRepository } from './seller-swap-inscription.repository'; 14 | 15 | @Module({ 16 | imports: [InscriptionModule, UserModule, PsbtModule], 17 | providers: [ 18 | SwapOfferService, 19 | SwapOfferRepository, 20 | BuyerSwapInscriptionRepository, 21 | SellerSwapInscriptionRepository, 22 | InscriptionService, 23 | PsbtService, 24 | BuyerSwapInscription, 25 | ], 26 | controllers: [SwapOfferController], 27 | }) 28 | export class SwapOfferModule {} 29 | -------------------------------------------------------------------------------- /src/app-bootstrap.manager.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, ValidationPipe } from '@nestjs/common'; 2 | import { Test, TestingModuleBuilder } from '@nestjs/testing'; 3 | import { json } from 'express'; 4 | import { useContainer } from 'class-validator'; 5 | import { AppModule } from './app.module'; 6 | import { InternalServerErrorExceptionsFilter } from './common/filters/internal-server-error-exceptions.filter'; 7 | 8 | export class AppBootstrapManager { 9 | static getTestingModuleBuilder(): TestingModuleBuilder { 10 | return Test.createTestingModule({ 11 | imports: [AppModule], 12 | }); 13 | } 14 | 15 | static setAppDefaults(app: INestApplication): INestApplication { 16 | useContainer(app.select(AppModule), { 17 | fallbackOnErrors: true, 18 | fallback: true, 19 | }); 20 | 21 | app 22 | .use(json({ limit: '50mb' })) 23 | .setGlobalPrefix('api/v1') 24 | .useGlobalFilters(new InternalServerErrorExceptionsFilter()) 25 | .useGlobalPipes( 26 | new ValidationPipe({ 27 | whitelist: true, 28 | validationError: { 29 | target: false, 30 | }, 31 | stopAtFirstError: true, 32 | }), 33 | ) 34 | .enableCors(); 35 | 36 | return app; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/auth/dto/login-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { WalletTypes } from '@src/user/user.entity'; 3 | import { IsEnum, IsString, MaxLength, MinLength } from 'class-validator'; 4 | 5 | export class LoginUserDto { 6 | @ApiProperty({ 7 | example: 'tb1pn952y2hrpzf9gfnmsg0zht2smhn2lrzxz569vtpt23aj8wqgndmsc4g58d', 8 | required: true, 9 | minimum: 1, 10 | maximum: 128, 11 | description: 'Wallet Address', 12 | }) 13 | @IsString() 14 | @MinLength(1) 15 | @MaxLength(128) 16 | address: string; 17 | 18 | @ApiProperty({ 19 | example: 20 | '032b6dc2ca805cf1602be02ea992e29772ff4b3575b3ac464692077d885afb6870', 21 | required: true, 22 | minimum: 1, 23 | maximum: 128, 24 | description: 'Public Key', 25 | }) 26 | @IsString() 27 | @MinLength(1) 28 | @MaxLength(128) 29 | pubkey: string; 30 | 31 | @ApiProperty({ 32 | example: '123456....', 33 | required: true, 34 | maximum: 255, 35 | description: 'signature', 36 | }) 37 | @MaxLength(255) 38 | signature: string; 39 | 40 | @ApiProperty({ 41 | example: 'Unisat', 42 | required: true, 43 | maximum: 255, 44 | description: 'Wallet type', 45 | }) 46 | @IsEnum(WalletTypes) 47 | walletType: WalletTypes; 48 | } 49 | -------------------------------------------------------------------------------- /src/config/datasource.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from 'typeorm'; 2 | import path = require('path'); 3 | import * as dotenv from 'dotenv'; 4 | import { EnvHelper } from '@src/common/helpers/env.helper'; 5 | import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; 6 | 7 | dotenv.config({ path: EnvHelper.getEnvFilePath() }); 8 | 9 | export const AppDataSource = new DataSource({ 10 | type: 'postgres', 11 | host: process.env.TYPEORM_HOST, 12 | port: Number(process.env.TYPEORM_PORT), 13 | username: process.env.TYPEORM_USERNAME, 14 | password: process.env.TYPEORM_PASSWORD, 15 | database: process.env.TYPEORM_DATABASE, 16 | entities: [path.join(__dirname, '../**/*.entity{.ts,.js}')], 17 | subscribers: [path.join(__dirname, '../**/*.subscriber{.ts,.js}')], 18 | synchronize: true, 19 | logging: process.env.TYPEORM_LOGGING === 'true', 20 | migrations: [path.join(__dirname, '../database/migrations/*')], 21 | extra: { 22 | connectionLimit: process.env.POSTGRESQL_CONNECTION_LIMIT || 200, 23 | waitForConnections: process.env.POSTGRESQL_WAIT_FOR_CONNECTIONS === 'true', 24 | }, 25 | poolSize: Number(process.env.TYPEORM_POOL_SIZE), 26 | namingStrategy: new SnakeNamingStrategy(), 27 | ssl: { rejectUnauthorized: process.env.POSTGRESQL_TLS === 'true' }, 28 | }); 29 | -------------------------------------------------------------------------------- /src/config/database.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import path = require('path'); 3 | 4 | export default registerAs('databaseConfig', () => ({ 5 | type: process.env.TYPEORM_CONNECTION || 'postgres', 6 | host: process.env.TYPEORM_HOST, 7 | port: process.env.TYPEORM_PORT || 5432, 8 | username: process.env.TYPEORM_USERNAME, 9 | password: process.env.TYPEORM_PASSWORD, 10 | database: process.env.TYPEORM_DATABASE, 11 | entities: [path.join(__dirname, '../**/*.entity{.ts,.js}')], 12 | subscribers: [path.join(__dirname, '../**/*.subscriber{.ts,.js}')], 13 | synchronize: true, 14 | logging: process.env.TYPEORM_LOGGING === 'true', 15 | migrationsTableName: 'migrations', 16 | migrations: [path.join(__dirname, '../migrations/*{.ts,.js')], 17 | charset: 'utf8mb4_unicode_ci', 18 | seeds: [path.join(__dirname, '../database/seeds/**/*{.ts,.js}')], 19 | factories: [path.join(__dirname, '../database/factories/**/*{.ts,.js}')], 20 | cli: { 21 | entitiesDir: 'src/**/', 22 | migrationsDir: process.env.TYPEORM_MIGRATIONS_DIR, 23 | }, 24 | legacySpatialSupport: false, 25 | extra: { 26 | connectionLimit: process.env.POSTGRESQL_CONNECTION_LIMIT || 200, 27 | waitForConnections: process.env.POSTGRESQL_WAIT_FOR_CONNECTIONS === 'true', 28 | }, 29 | poolSize: process.env.TYPEORM_POOL_SIZE, 30 | ssl: false, 31 | })); 32 | -------------------------------------------------------------------------------- /src/auth/sign-message.entity.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Exclude } from 'class-transformer'; 3 | import { 4 | CreateDateColumn, 5 | Column, 6 | DeleteDateColumn, 7 | Entity, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | } from 'typeorm'; 11 | 12 | @Entity('sign_message') 13 | export class SignMessage { 14 | @Exclude({ toPlainOnly: true }) 15 | @PrimaryGeneratedColumn({ name: 'id' }) 16 | id: number; 17 | 18 | @ApiProperty({ description: `Unique uuid`, maximum: 36 }) 19 | @Column({ type: 'varchar', nullable: false, length: 36 }) 20 | uuid: string; 21 | 22 | @ApiProperty({ description: 'Sign Message', maximum: 128, required: false }) 23 | @Column({ type: 'varchar', nullable: true, length: 128 }) 24 | message: string; 25 | 26 | @ApiProperty({ description: 'Address', maximum: 255, required: true }) 27 | @Column({ type: 'varchar', nullable: false, length: 255 }) 28 | address: string; 29 | 30 | @ApiProperty({ 31 | description: 'Date when the message was created', 32 | required: true, 33 | }) 34 | @CreateDateColumn() 35 | createdAt: Date; 36 | 37 | @ApiProperty({ 38 | description: 'Date when message was updated the last time', 39 | required: false, 40 | }) 41 | @UpdateDateColumn() 42 | updatedAt: Date; 43 | 44 | @Exclude({ toPlainOnly: true }) 45 | @DeleteDateColumn() 46 | deletedAt: Date; 47 | } 48 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, HttpStatus, Post } from '@nestjs/common'; 2 | import { ApiOperation, ApiResponse } from '@nestjs/swagger'; 3 | 4 | import { AuthService } from '@src/auth/auth.service'; 5 | import { ApiResponseHelper } from '@src/common/helpers/api-response.helper'; 6 | import { LoginUserDto } from './dto/login-user.dto'; 7 | import { GetSignMessageDto } from './dto/get-sign-message.dto'; 8 | import { AccessToken, GenerateMessage } from './auth.type'; 9 | 10 | @Controller('auth') 11 | export class AuthController { 12 | constructor(private readonly authService: AuthService) {} 13 | 14 | @ApiOperation({ description: `User login`, tags: ['Auth'] }) 15 | @ApiResponse(ApiResponseHelper.success(AccessToken, HttpStatus.CREATED)) 16 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 17 | @Post('/login') 18 | async login(@Body() body: LoginUserDto): Promise { 19 | const user = await this.authService.validateUser(body); 20 | const authData = await this.authService.login(user); 21 | return { accessToken: authData.accessToken }; 22 | } 23 | 24 | @ApiOperation({ 25 | description: `Generate sign message for bip 322 verification `, 26 | tags: ['Auth'], 27 | }) 28 | @ApiResponse(ApiResponseHelper.success(GenerateMessage, HttpStatus.CREATED)) 29 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 30 | @Post('/generate-message') 31 | async generateSignMessage( 32 | @Body() body: GetSignMessageDto, 33 | ): Promise { 34 | return this.authService.generateSignMessage(body.address); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/swap-offer/buyer-swap-inscription.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateDateColumn, 3 | Column, 4 | DeleteDateColumn, 5 | Entity, 6 | PrimaryGeneratedColumn, 7 | UpdateDateColumn, 8 | ManyToOne, 9 | } from 'typeorm'; 10 | import { ApiProperty } from '@nestjs/swagger'; 11 | import { Exclude } from 'class-transformer'; 12 | 13 | import { Inscription } from '@src/inscription/inscription.entity'; 14 | import { SwapOffer } from './swap-offer.entity'; 15 | 16 | @Entity('buyer_swap_inscription') 17 | export class BuyerSwapInscription { 18 | @Exclude({ toPlainOnly: true }) 19 | @PrimaryGeneratedColumn({ name: 'id' }) 20 | id: number; 21 | 22 | @ApiProperty({ description: `Unique uuid`, maximum: 36 }) 23 | @Column({ type: 'varchar', nullable: false, length: 36 }) 24 | uuid: string; 25 | 26 | @Column({ type: 'integer', nullable: false }) 27 | inscriptionId: number; 28 | 29 | @Column({ type: 'integer', nullable: false }) 30 | swapOfferId: number; 31 | 32 | @ApiProperty({ 33 | description: 'Date when the user was created', 34 | required: true, 35 | }) 36 | @CreateDateColumn() 37 | createdAt: Date; 38 | 39 | @ApiProperty({ 40 | description: 'Date when user was updated the last time', 41 | required: false, 42 | }) 43 | @UpdateDateColumn() 44 | updatedAt: Date; 45 | 46 | @Exclude({ toPlainOnly: true }) 47 | @DeleteDateColumn() 48 | deletedAt: Date; 49 | 50 | @ManyToOne( 51 | () => Inscription, 52 | (inscription) => inscription.buyerSwapInscription, 53 | ) 54 | inscription: Inscription; 55 | 56 | @ManyToOne(() => SwapOffer, (swapOffer) => swapOffer.buyerSwapInscription) 57 | swapOffer: SwapOffer; 58 | } 59 | -------------------------------------------------------------------------------- /src/swap-offer/seller-swap-inscription.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateDateColumn, 3 | Column, 4 | DeleteDateColumn, 5 | Entity, 6 | PrimaryGeneratedColumn, 7 | UpdateDateColumn, 8 | ManyToOne, 9 | OneToMany, 10 | } from 'typeorm'; 11 | import { ApiProperty } from '@nestjs/swagger'; 12 | import { Exclude } from 'class-transformer'; 13 | 14 | import { Inscription } from '@src/inscription/inscription.entity'; 15 | import { SwapOffer } from './swap-offer.entity'; 16 | 17 | @Entity('seller_swap_inscription') 18 | export class SellerSwapInscription { 19 | @Exclude({ toPlainOnly: true }) 20 | @PrimaryGeneratedColumn({ name: 'id' }) 21 | id: number; 22 | 23 | @ApiProperty({ description: `Unique uuid`, maximum: 36 }) 24 | @Column({ type: 'varchar', nullable: false, length: 36 }) 25 | uuid: string; 26 | 27 | @Column({ type: 'integer', nullable: false }) 28 | inscriptionId: number; 29 | 30 | @Column({ type: 'integer', nullable: false }) 31 | swapOfferId: number; 32 | 33 | @ApiProperty({ 34 | description: 'Date when the user was created', 35 | required: true, 36 | }) 37 | @CreateDateColumn() 38 | createdAt: Date; 39 | 40 | @ApiProperty({ 41 | description: 'Date when user was updated the last time', 42 | required: false, 43 | }) 44 | @UpdateDateColumn() 45 | updatedAt: Date; 46 | 47 | @Exclude({ toPlainOnly: true }) 48 | @DeleteDateColumn() 49 | deletedAt: Date; 50 | 51 | @ManyToOne( 52 | () => Inscription, 53 | (inscription) => inscription.sellerSwapInscription, 54 | ) 55 | inscription: Inscription; 56 | 57 | @ManyToOne(() => SwapOffer, (swapOffer) => swapOffer.sellerSwapInscription) 58 | swapOffer: SwapOffer; 59 | } 60 | -------------------------------------------------------------------------------- /src/file/file.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | HttpStatus, 4 | Post, 5 | UploadedFile, 6 | UseGuards, 7 | UseInterceptors, 8 | } from '@nestjs/common'; 9 | import { FileInterceptor } from '@nestjs/platform-express'; 10 | import { 11 | ApiBearerAuth, 12 | ApiBody, 13 | ApiOperation, 14 | ApiResponse, 15 | } from '@nestjs/swagger'; 16 | 17 | import { Role } from '@src/auth/role/role.decorator'; 18 | import { ApiResponseHelper } from '@src/common/helpers/api-response.helper'; 19 | import { JwtAuthGuard } from '@src/auth/guards/jwt-auth.guard'; 20 | import { RoleGuard } from '@src/auth/role/role.guard'; 21 | import { FileService } from './file.service'; 22 | import { UploadImage } from './file.type'; 23 | 24 | @Controller('file') 25 | export class FileController { 26 | constructor(private readonly fileService: FileService) {} 27 | 28 | @ApiBearerAuth() 29 | @Role('Admin') 30 | @UseGuards(JwtAuthGuard, RoleGuard) 31 | @ApiOperation({ 32 | description: `Upload an image to s3 `, 33 | tags: ['Image'], 34 | }) 35 | @ApiBody({ 36 | required: true, 37 | type: 'multipart/form-data', 38 | schema: { 39 | type: 'object', 40 | properties: { 41 | file: { 42 | type: 'string', 43 | format: 'binary', 44 | }, 45 | }, 46 | }, 47 | }) 48 | @ApiResponse(ApiResponseHelper.success(UploadImage, HttpStatus.CREATED)) 49 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 50 | @Post('upload') 51 | @UseInterceptors(FileInterceptor('file')) 52 | async upload( 53 | @UploadedFile() file: Express.Multer.File, 54 | ): Promise<{ imageUrl: string }> { 55 | const imageUrl = await this.fileService.upload(file); 56 | return { imageUrl }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/search/search.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, HttpStatus, Param, Query } from '@nestjs/common'; 2 | 3 | import { ApiResponseHelper } from '@src/common/helpers/api-response.helper'; 4 | import { SearchService } from './search.service'; 5 | import { SearchDto } from './dto/search.dto'; 6 | import { ApiOperation, ApiResponse } from '@nestjs/swagger'; 7 | import { SearchResult } from './search.type'; 8 | 9 | @Controller('search') 10 | export class SearchController { 11 | constructor(private readonly searchService: SearchService) {} 12 | 13 | @ApiOperation({ 14 | description: `Search inscriptions`, 15 | tags: ['Search'], 16 | }) 17 | @ApiResponse(ApiResponseHelper.success(SearchResult, HttpStatus.OK)) 18 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 19 | @Get() 20 | async search(@Query() body: SearchDto) { 21 | return this.searchService.search(body.keyword); 22 | } 23 | 24 | @ApiOperation({ 25 | description: `Search inscriptions by address`, 26 | tags: ['Search'], 27 | }) 28 | @ApiResponse(ApiResponseHelper.success(SearchResult, HttpStatus.OK)) 29 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 30 | @Get('/address/:address') 31 | async searchByAddress(@Param('address') address: string) { 32 | return this.searchService.searchByAddress(address); 33 | } 34 | 35 | @ApiOperation({ 36 | description: `Search inscriptions by inscription id`, 37 | tags: ['Search'], 38 | }) 39 | @ApiResponse(ApiResponseHelper.success(SearchResult, HttpStatus.OK)) 40 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 41 | @Get('/inscription/:inscriptionId') 42 | async searchByInscriptionID(@Param('inscriptionId') inscriptionId: string) { 43 | return this.searchService.searchInscription(inscriptionId); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/swap-offer/dto/generate-swap-psbt.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | ArrayMaxSize, 4 | ArrayMinSize, 5 | IsArray, 6 | IsEnum, 7 | IsIn, 8 | IsNumber, 9 | IsOptional, 10 | IsString, 11 | } from 'class-validator'; 12 | 13 | import { WalletTypes } from '@src/user/user.entity'; 14 | 15 | export class GenerateSwapPsbtDto { 16 | @ApiProperty({ 17 | example: [ 18 | 'e19eea22dc6f6fc16c5a6aad4f6c7bdfe16733def97be0f6cb1c5d12ede37dbfi0', 19 | ], 20 | required: false, 21 | minimum: 1, 22 | maximum: 128, 23 | description: 'Buyer Inscription Id', 24 | }) 25 | @IsArray() 26 | @IsString({ each: true }) 27 | @ArrayMinSize(1) 28 | @ArrayMaxSize(4) 29 | buyerInscriptionIds: string[]; 30 | 31 | @ApiProperty({ 32 | example: [ 33 | 'e19eea22dc6f6fc16c5a6aad4f6c7bdfe16733def97be0f6cb1c5d12ede37dbfi0', 34 | ], 35 | required: false, 36 | minimum: 1, 37 | maximum: 128, 38 | description: 'Buyer Inscription Id', 39 | }) 40 | @IsArray() 41 | @IsString({ each: true }) 42 | @ArrayMinSize(1) 43 | @ArrayMaxSize(4) 44 | sellerInscriptionIds: string[]; 45 | 46 | @ApiProperty({ 47 | example: 'Unisat', 48 | required: false, 49 | minimum: 1, 50 | maximum: 128, 51 | description: 'Buyer wallet type', 52 | }) 53 | @IsEnum(WalletTypes) 54 | walletType: WalletTypes; 55 | 56 | @ApiProperty({ 57 | example: '0.0002', 58 | required: false, 59 | minimum: 1, 60 | maximum: 128, 61 | description: 'Add price', 62 | }) 63 | @IsNumber() 64 | @IsOptional() 65 | price?: number; 66 | 67 | @ApiProperty({ 68 | example: '30m', 69 | required: false, 70 | minimum: 1, 71 | maximum: 128, 72 | description: 'Expired time', 73 | }) 74 | @IsIn(['30m', '1h', '6h', '1d', '3d', '7d']) 75 | expiredIn: string; 76 | } 77 | -------------------------------------------------------------------------------- /src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | 3 | import { LoginUserDto } from '@src/auth/dto/login-user.dto'; 4 | import { User } from './user.entity'; 5 | import { UserRepository } from './user.repository'; 6 | 7 | @Injectable() 8 | export class UserService { 9 | constructor(private readonly userRepository: UserRepository) {} 10 | 11 | async findByAddress(address: string): Promise { 12 | return this.userRepository.findOne({ where: { address } }); 13 | } 14 | 15 | async findByUuid(uuid: string): Promise { 16 | return this.userRepository.findOne({ where: { uuid } }); 17 | } 18 | 19 | async create(body: LoginUserDto): Promise { 20 | const userEntity: Partial = { 21 | ...this.userRepository.create(body), 22 | }; 23 | 24 | const user = await this.userRepository.save(userEntity, { reload: false }); 25 | 26 | return this.findByUuid(user.uuid); 27 | } 28 | 29 | async createWithAddress(address: string): Promise { 30 | const userEntity: Partial = { 31 | address, 32 | }; 33 | 34 | const user = await this.userRepository.save(userEntity, { reload: false }); 35 | 36 | return this.findByUuid(user.uuid); 37 | } 38 | 39 | async findOne(id: number): Promise { 40 | return this.userRepository.findOne({ where: { id } }); 41 | } 42 | 43 | async search(keyWord: string): Promise[]> { 44 | const users = await this.userRepository 45 | .createQueryBuilder('user') 46 | .where('LOWER(name) LIKE LOWER(:search)', { 47 | search: `%${keyWord}%`, 48 | }) 49 | .orWhere('LOWER(address) LIKE LOWER(:search)', { 50 | search: `%${keyWord}%`, 51 | }) 52 | .orderBy('updated_at', 'DESC') 53 | .getRawAndEntities(); 54 | 55 | return users.entities.map((user) => { 56 | return { 57 | address: user.address, 58 | }; 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.db: -------------------------------------------------------------------------------- 1 | // Use DBML to define your database structure 2 | // Docs: https://dbml.dbdiagram.io/docs 3 | 4 | enum WalletTypes { 5 | unisat 6 | hiro 7 | xverse 8 | } 9 | 10 | enum Role { 11 | customer 12 | admin 13 | } 14 | 15 | enum OfferStatus { 16 | created // created the psbt 17 | signed //buy signed the psbt 18 | accepted // owner signed the psbt 19 | pushed // combine and pushed 20 | failed // failed when push transaction 21 | canceled // canceled by owner 22 | pending // waiting for push tx 23 | } 24 | 25 | enum ActivityStatus { 26 | created 27 | completed 28 | removed 29 | } 30 | 31 | Table user { 32 | id integer [pk] 33 | uuid varchar 34 | address varchar 35 | paymentAddress varchar 36 | pubkey varchar 37 | walletType WalletTypes 38 | role Role 39 | } 40 | 41 | Table collection { 42 | id integer [pk] 43 | uuid varchar 44 | name varchar 45 | image_url varchar 46 | description varchar 47 | website varchar 48 | twitter varchar 49 | discord varchar 50 | } 51 | 52 | Table inscription { 53 | id integer [pk] 54 | uuid varchar 55 | inscription_id varchar 56 | collection_id integer [ref: > collection.id] 57 | } 58 | 59 | Table swap_offer { 60 | id integer [pk] 61 | uuid varchar 62 | buyer_id int [ref: > user.id] 63 | seller_id int [ref: > user.id] 64 | price double 65 | status OfferStatus 66 | psbt varchar 67 | buyer_signed_psbt varchar 68 | seller_signed_psbt varchar 69 | tx_id varbinary 70 | expiredAt datetime 71 | } 72 | 73 | Table buyer_swap_inscription { 74 | id integer [pk] 75 | uuid varchar 76 | inscription_id int [ref:> inscription.id] 77 | swap_offer_id int [ref:> swap_offer.id] 78 | } 79 | 80 | Table seller_swap_inscription { 81 | id integer [pk] 82 | uuid varchar 83 | inscription_id int [ref:> inscription.id] 84 | swap_offer_id int [ref:> swap_offer.id] 85 | } 86 | 87 | Table sign_message { 88 | id integer [pk] 89 | uuid varchar 90 | address varchar 91 | mesage varchar 92 | } -------------------------------------------------------------------------------- /src/inscription/inscription.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateDateColumn, 3 | Column, 4 | DeleteDateColumn, 5 | Entity, 6 | PrimaryGeneratedColumn, 7 | UpdateDateColumn, 8 | ManyToOne, 9 | OneToMany, 10 | } from 'typeorm'; 11 | import { ApiProperty } from '@nestjs/swagger'; 12 | import { Exclude } from 'class-transformer'; 13 | 14 | import { Collection } from '@src/collection/collection.entity'; 15 | import { BuyerSwapInscription } from '@src/swap-offer/buyer-swap-inscription.entity'; 16 | import { SellerSwapInscription } from '@src/swap-offer/seller-swap-inscription.entity'; 17 | 18 | @Entity('inscription') 19 | export class Inscription { 20 | @Exclude({ toPlainOnly: true }) 21 | @PrimaryGeneratedColumn({ name: 'id' }) 22 | id: number; 23 | 24 | @ApiProperty({ description: `Unique uuid`, maximum: 36 }) 25 | @Column({ type: 'varchar', nullable: false, length: 36 }) 26 | uuid: string; 27 | 28 | @ApiProperty({ description: `Inscription Id`, maximum: 36 }) 29 | @Column({ type: 'varchar', nullable: false }) 30 | inscriptionId: string; 31 | 32 | @Column({ type: 'integer', nullable: false }) 33 | collectionId: number; 34 | 35 | @ManyToOne(() => Collection, (collection) => collection.inscription) 36 | collection: Collection; 37 | 38 | @ApiProperty({ 39 | description: 'Date when the user was created', 40 | required: true, 41 | }) 42 | @CreateDateColumn() 43 | createdAt: Date; 44 | 45 | @ApiProperty({ 46 | description: 'Date when user was updated the last time', 47 | required: false, 48 | }) 49 | @UpdateDateColumn() 50 | updatedAt: Date; 51 | 52 | @Exclude({ toPlainOnly: true }) 53 | @DeleteDateColumn() 54 | deletedAt: Date; 55 | 56 | @OneToMany( 57 | () => BuyerSwapInscription, 58 | (buyerSwapInscription) => buyerSwapInscription.inscription, 59 | ) 60 | buyerSwapInscription: BuyerSwapInscription[]; 61 | 62 | @OneToMany( 63 | () => SellerSwapInscription, 64 | (sellerSwapInscription) => sellerSwapInscription.inscription, 65 | ) 66 | sellerSwapInscription: SellerSwapInscription[]; 67 | } 68 | -------------------------------------------------------------------------------- /src/user/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | IsEmail, 4 | IsOptional, 5 | IsString, 6 | MaxLength, 7 | MinLength, 8 | } from 'class-validator'; 9 | 10 | export class UpdateUserDto { 11 | @ApiProperty({ 12 | example: 'John Doe', 13 | required: false, 14 | minimum: 1, 15 | maximum: 128, 16 | description: 'Display name', 17 | }) 18 | @IsString() 19 | @MinLength(1) 20 | @MaxLength(128) 21 | name: string; 22 | 23 | @ApiProperty({ 24 | example: 'user@example.com', 25 | required: false, 26 | maximum: 255, 27 | description: 'E-mail', 28 | }) 29 | @IsEmail() 30 | @MaxLength(255) 31 | email: string; 32 | 33 | @ApiProperty({ 34 | example: 'My ...', 35 | required: false, 36 | minimum: 1, 37 | maximum: 128, 38 | description: 'Bio', 39 | }) 40 | @IsString() 41 | @MinLength(1) 42 | @MaxLength(255) 43 | bio: string; 44 | 45 | @ApiProperty({ 46 | example: 'https://', 47 | required: false, 48 | minimum: 1, 49 | maximum: 128, 50 | description: 'Website', 51 | }) 52 | @IsString() 53 | @MinLength(1) 54 | @MaxLength(255) 55 | @IsOptional() 56 | website: string; 57 | 58 | @ApiProperty({ 59 | example: 'https://', 60 | required: false, 61 | minimum: 1, 62 | maximum: 128, 63 | description: 'Twitter', 64 | }) 65 | @IsString() 66 | @MinLength(1) 67 | @MaxLength(255) 68 | @IsOptional() 69 | twitter: string; 70 | 71 | @ApiProperty({ 72 | example: 'https://', 73 | required: false, 74 | minimum: 1, 75 | maximum: 128, 76 | description: 'Facebook', 77 | }) 78 | @IsString() 79 | @MinLength(1) 80 | @MaxLength(255) 81 | @IsOptional() 82 | facebook: string; 83 | 84 | @ApiProperty({ 85 | example: 'tb1pn952y2hrpzf9gfnmsg0zht2smhn2lrzxz569vtpt23aj8wqgndmsc4g58d', 86 | required: true, 87 | minimum: 1, 88 | maximum: 128, 89 | description: 'Wallet payment Address', 90 | }) 91 | @IsString() 92 | @MinLength(1) 93 | @MaxLength(128) 94 | paymentAddress: string; 95 | } 96 | -------------------------------------------------------------------------------- /src/collection/collection.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | UseGuards, 5 | Body, 6 | Get, 7 | Param, 8 | Query, 9 | HttpStatus, 10 | } from '@nestjs/common'; 11 | import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; 12 | 13 | import { JwtAuthGuard } from '@src/auth/guards/jwt-auth.guard'; 14 | import { RoleGuard } from '@src/auth/role/role.guard'; 15 | import { Role } from '@src/auth/role/role.decorator'; 16 | import { PageOptionsDto } from '@src/common/pagination/pagination.types'; 17 | import { ApiResponseHelper } from '@src/common/helpers/api-response.helper'; 18 | import { CollectionService } from './collection.service'; 19 | import { CreateCollectionDto } from './dto/create-collection.dto'; 20 | import { Collection } from './collection.entity'; 21 | import { CollectionInscriptions } from './collection.type'; 22 | 23 | @Controller('collection') 24 | export class CollectionController { 25 | constructor(private collectionService: CollectionService) {} 26 | 27 | @ApiBearerAuth() 28 | @Role('Admin') 29 | @UseGuards(JwtAuthGuard, RoleGuard) 30 | @ApiOperation({ description: `Create a collection`, tags: ['Collection'] }) 31 | @ApiResponse(ApiResponseHelper.success(Collection, HttpStatus.CREATED)) 32 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 33 | @Post('/create') 34 | async create(@Body() body: CreateCollectionDto): Promise { 35 | return this.collectionService.createCollection(body); 36 | } 37 | 38 | @ApiOperation({ 39 | description: `Get inscriptions by collection name`, 40 | tags: ['Collection'], 41 | }) 42 | @ApiResponse(ApiResponseHelper.success(CollectionInscriptions, HttpStatus.OK)) 43 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 44 | @Get('/:collectionName') 45 | async getCollectionInfo( 46 | @Param('collectionName') collectionName: string, 47 | @Query() pageOptionsDto: PageOptionsDto, 48 | ) { 49 | return this.collectionService.getCollectionInfo( 50 | collectionName, 51 | pageOptionsDto, 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/collection/collection.entity.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Exclude } from 'class-transformer'; 3 | import { 4 | CreateDateColumn, 5 | Column, 6 | DeleteDateColumn, 7 | Entity, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | OneToMany, 11 | } from 'typeorm'; 12 | 13 | import { Inscription } from '@src/inscription/inscription.entity'; 14 | 15 | @Entity('collection') 16 | export class Collection { 17 | @Exclude({ toPlainOnly: true }) 18 | @PrimaryGeneratedColumn({ name: 'id' }) 19 | id: number; 20 | 21 | @ApiProperty({ description: `Unique uuid`, maximum: 36 }) 22 | @Column({ type: 'varchar', nullable: false, length: 36 }) 23 | uuid: string; 24 | 25 | @ApiProperty({ description: `name`, maximum: 80 }) 26 | @Column({ type: 'varchar', nullable: true, length: 80 }) 27 | name: string; 28 | 29 | @ApiProperty({ description: `Description`, maximum: 1200 }) 30 | @Column({ type: 'varchar', nullable: true, length: 1200 }) 31 | description: string; 32 | 33 | @ApiProperty({ description: `Banner Image Url`, maximum: 255 }) 34 | @Column({ type: 'varchar', nullable: true, length: 255 }) 35 | imgUrl: string; 36 | 37 | @ApiProperty({ description: `Website Url`, maximum: 255 }) 38 | @Column({ type: 'varchar', nullable: true, length: 255 }) 39 | website: string; 40 | 41 | @ApiProperty({ description: `Twitter Url`, maximum: 255 }) 42 | @Column({ type: 'varchar', nullable: true, length: 255 }) 43 | twitter: string; 44 | 45 | @ApiProperty({ description: `Discordp Url`, maximum: 255 }) 46 | @Column({ type: 'varchar', nullable: true, length: 255 }) 47 | discord: string; 48 | 49 | @OneToMany(() => Inscription, (inscription) => inscription.collection) 50 | inscription: Inscription[]; 51 | 52 | @ApiProperty({ 53 | description: 'Date when the user was created', 54 | required: true, 55 | }) 56 | @CreateDateColumn() 57 | createdAt: Date; 58 | 59 | @ApiProperty({ 60 | description: 'Date when user was updated the last time', 61 | required: false, 62 | }) 63 | @UpdateDateColumn() 64 | updatedAt: Date; 65 | 66 | @Exclude({ toPlainOnly: true }) 67 | @DeleteDateColumn() 68 | deletedAt: Date; 69 | } 70 | -------------------------------------------------------------------------------- /src/common/pagination/pagination.types.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { ApiPropertyOptional } from '@nestjs/swagger'; 3 | import { Type } from 'class-transformer'; 4 | import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator'; 5 | 6 | export enum Order { 7 | ASC = 'ASC', 8 | DESC = 'DESC', 9 | } 10 | 11 | export class PageOptionsDto { 12 | @ApiPropertyOptional({ enum: Order, default: Order.ASC }) 13 | @IsEnum(Order) 14 | @IsOptional() 15 | readonly order?: Order = Order.DESC; 16 | 17 | @ApiPropertyOptional({ 18 | minimum: 1, 19 | default: 1, 20 | }) 21 | @Type(() => Number) 22 | @IsInt() 23 | @Min(1) 24 | @IsOptional() 25 | readonly page?: number = 1; 26 | 27 | @ApiPropertyOptional({ 28 | minimum: 1, 29 | maximum: 50, 30 | default: 10, 31 | }) 32 | @Type(() => Number) 33 | @IsInt() 34 | @Min(1) 35 | @Max(50) 36 | @IsOptional() 37 | readonly take?: number = 10; 38 | 39 | get skip(): number { 40 | return (this.page - 1) * this.take; 41 | } 42 | } 43 | 44 | export interface PageMetaDtoParameters { 45 | pageOptionsDto: PageOptionsDto; 46 | itemCount: number; 47 | } 48 | 49 | export class PageMetaDto { 50 | @ApiProperty() 51 | readonly page: number; 52 | 53 | @ApiProperty() 54 | readonly take: number; 55 | 56 | @ApiProperty() 57 | readonly itemCount: number; 58 | 59 | @ApiProperty() 60 | readonly pageCount: number; 61 | 62 | @ApiProperty() 63 | readonly hasPreviousPage: boolean; 64 | 65 | @ApiProperty() 66 | readonly hasNextPage: boolean; 67 | 68 | constructor({ pageOptionsDto, itemCount }: PageMetaDtoParameters) { 69 | this.page = pageOptionsDto.page; 70 | this.take = pageOptionsDto.take; 71 | this.itemCount = itemCount; 72 | this.pageCount = Math.ceil(this.itemCount / this.take); 73 | this.hasPreviousPage = this.page > 1; 74 | this.hasNextPage = this.page < this.pageCount; 75 | } 76 | } 77 | 78 | export class PageDto { 79 | @ApiProperty({ isArray: true }) 80 | readonly data: T[]; 81 | 82 | @ApiProperty({ type: () => PageMetaDto }) 83 | readonly meta: PageMetaDto; 84 | 85 | constructor(data: T[], meta: PageMetaDto) { 86 | this.data = data; 87 | this.meta = meta; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ################### 2 | # BUILD FOR LOCAL DEVELOPMENT 3 | ################### 4 | 5 | FROM node:18-alpine As development 6 | 7 | # Create app directory 8 | WORKDIR /usr/src/app 9 | 10 | # Copy application dependency manifests to the container image. 11 | # A wildcard is used to ensure copying both package.json AND package-lock.json (when available). 12 | # Copying this first prevents re-running npm install on every code change. 13 | COPY --chown=node:node package*.json ./ 14 | 15 | # Install app dependencies using the `npm ci` command instead of `npm install` 16 | RUN npm ci 17 | 18 | # Bundle app source 19 | COPY --chown=node:node . . 20 | 21 | # Use the node user from the image (instead of the root user) 22 | USER node 23 | 24 | ################### 25 | # BUILD FOR PRODUCTION 26 | ################### 27 | 28 | FROM node:18-alpine As build 29 | 30 | WORKDIR /usr/src/app 31 | 32 | COPY --chown=node:node package*.json ./ 33 | 34 | # In order to run `npm run build` we need access to the Nest CLI which is a dev dependency. In the previous development stage we ran `npm ci` which installed all dependencies, so we can copy over the node_modules directory from the development image 35 | COPY --chown=node:node --from=development /usr/src/app/node_modules ./node_modules 36 | 37 | COPY --chown=node:node . . 38 | 39 | # Run the build command which creates the production bundle 40 | RUN npm run build 41 | 42 | # Set NODE_ENV environment variable 43 | ENV NODE_ENV production 44 | 45 | # Set script prepare 46 | # RUN npm set-script prepare '' 47 | 48 | # Running `npm ci` removes the existing node_modules directory and passing in --only=production ensures that only the production dependencies are installed. This ensures that the node_modules directory is as optimized as possible 49 | RUN npm ci --only=production && npm cache clean --force 50 | 51 | USER node 52 | 53 | ################### 54 | # PRODUCTION 55 | ################### 56 | 57 | FROM node:18-alpine As production 58 | 59 | # Copy the bundled code from the build stage to the production image 60 | COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules 61 | COPY --chown=node:node --from=build /usr/src/app/dist ./dist 62 | 63 | # Start the server using the production build 64 | CMD [ "node", "dist/main.js" ] 65 | 66 | -------------------------------------------------------------------------------- /src/inscription/inscription.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Controller, 4 | Get, 5 | HttpStatus, 6 | Param, 7 | Query, 8 | Request, 9 | UseGuards, 10 | } from '@nestjs/common'; 11 | import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; 12 | 13 | import { JwtAuthGuard } from '@src/auth/guards/jwt-auth.guard'; 14 | import { 15 | PageDto, 16 | PageOptionsDto, 17 | } from '@src/common/pagination/pagination.types'; 18 | import { InscriptionService } from './inscription.service'; 19 | import { InscriptionInfo } from './inscription.type'; 20 | import { ApiResponseHelper } from '@src/common/helpers/api-response.helper'; 21 | 22 | @Controller('inscription') 23 | export class InscriptionController { 24 | constructor(private inscriptionService: InscriptionService) {} 25 | 26 | @ApiOperation({ 27 | description: `Get inscription buy now price information`, 28 | tags: ['Inscription'], 29 | }) 30 | @ApiResponse(ApiResponseHelper.success(InscriptionInfo, HttpStatus.OK)) 31 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 32 | @Get('/inscription-info/:inscriptionId') 33 | async getInscriptionInfo(@Param('inscriptionId') inscriptionId: string) { 34 | const inscriptionInfo = await this.inscriptionService.getInscriptionInfo( 35 | inscriptionId, 36 | ); 37 | 38 | delete inscriptionInfo.id; 39 | delete inscriptionInfo.uuid; 40 | delete inscriptionInfo.collectionId; 41 | delete inscriptionInfo.createdAt; 42 | delete inscriptionInfo.updatedAt; 43 | delete inscriptionInfo.deletedAt; 44 | 45 | if (inscriptionInfo) return inscriptionInfo; 46 | 47 | throw new BadRequestException( 48 | 'Can not find an information of this inscription', 49 | ); 50 | } 51 | 52 | @ApiBearerAuth() 53 | @UseGuards(JwtAuthGuard) 54 | @ApiOperation({ 55 | description: `Get owned inscription infos`, 56 | tags: ['Inscription'], 57 | }) 58 | @ApiResponse( 59 | ApiResponseHelper.success(PageDto, HttpStatus.OK), 60 | ) 61 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 62 | @Get('/inscriptions') 63 | async getInscriptions( 64 | @Request() req, 65 | @Query() pageOptionsDto: PageOptionsDto, 66 | ) { 67 | return this.inscriptionService.getOwnedInscriptions( 68 | req.user.address, 69 | pageOptionsDto, 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { SwapOffer } from '@src/swap-offer/swap-offer.entity'; 3 | import { Exclude } from 'class-transformer'; 4 | import { 5 | CreateDateColumn, 6 | Column, 7 | DeleteDateColumn, 8 | Entity, 9 | PrimaryGeneratedColumn, 10 | UpdateDateColumn, 11 | OneToMany, 12 | } from 'typeorm'; 13 | 14 | export enum WalletTypes { 15 | UNISAT = 'Unisat', 16 | XVERSE = 'Xverse', 17 | HIRO = 'Hiro', 18 | } 19 | 20 | export enum Role { 21 | CUSTOMER = 'Customer', 22 | ADMIN = 'Admin', 23 | } 24 | 25 | @Entity('user') 26 | export class User { 27 | @Exclude({ toPlainOnly: true }) 28 | @PrimaryGeneratedColumn({ name: 'id' }) 29 | id: number; 30 | 31 | @ApiProperty({ description: `Unique uuid`, maximum: 36 }) 32 | @Column({ type: 'varchar', nullable: false, length: 36 }) 33 | uuid: string; 34 | 35 | @ApiProperty({ description: 'Public key', maximum: 255, required: false }) 36 | @Column({ 37 | type: 'varchar', 38 | nullable: true, 39 | length: 255, 40 | }) 41 | pubkey: string; 42 | 43 | @ApiProperty({ description: 'Address', maximum: 255, required: false }) 44 | @Column({ type: 'varchar', nullable: false, length: 255 }) 45 | address: string; 46 | 47 | @ApiProperty({ 48 | description: 'Payment Address', 49 | maximum: 255, 50 | required: false, 51 | }) 52 | @Column({ type: 'varchar', nullable: true, length: 255, default: '' }) 53 | paymentAddress: string; 54 | 55 | @ApiProperty({ description: 'Wallet type', maximum: 255, required: false }) 56 | @Column({ type: 'enum', enum: WalletTypes, nullable: true }) 57 | walletType: WalletTypes; 58 | 59 | @ApiProperty({ description: 'Role', maximum: 255, required: false }) 60 | @Column({ type: 'enum', enum: Role, nullable: false, default: Role.CUSTOMER }) 61 | role: Role; 62 | 63 | @ApiProperty({ 64 | description: 'Date when the user was created', 65 | required: true, 66 | }) 67 | @CreateDateColumn() 68 | createdAt: Date; 69 | 70 | @ApiProperty({ 71 | description: 'Date when user was updated the last time', 72 | required: false, 73 | }) 74 | @UpdateDateColumn() 75 | updatedAt: Date; 76 | 77 | @Exclude({ toPlainOnly: true }) 78 | @DeleteDateColumn() 79 | deletedAt: Date; 80 | 81 | @OneToMany(() => SwapOffer, (swapOffer) => swapOffer.buyer) 82 | buyerswapOffer: SwapOffer; 83 | 84 | @OneToMany(() => SwapOffer, (swapOffer) => swapOffer.seller) 85 | sellerswapOffer: SwapOffer; 86 | } 87 | -------------------------------------------------------------------------------- /src/collection/dto/create-collection.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | ArrayMinSize, 4 | IsArray, 5 | IsOptional, 6 | IsString, 7 | MaxLength, 8 | MinLength, 9 | } from 'class-validator'; 10 | 11 | export class CreateCollectionDto { 12 | @ApiProperty({ 13 | example: 'Blockmunchers', 14 | required: false, 15 | minimum: 1, 16 | maximum: 128, 17 | description: 'Collection name', 18 | }) 19 | @IsString() 20 | @MinLength(1) 21 | @MaxLength(255) 22 | name: string; 23 | 24 | @ApiProperty({ 25 | example: 26 | 'https://img-cdn.magiceden.dev/rs:fill:400:400:0:0/plain/https://bafybeibtmemsukay4wf6wwaled4cnh7tljnzczwjssvte44a6bfw6h6eve.ipfs.nftstorage.link', 27 | required: false, 28 | minimum: 1, 29 | maximum: 128, 30 | description: '', 31 | }) 32 | @IsString() 33 | @MinLength(1) 34 | @MaxLength(255) 35 | imgUrl: string; 36 | 37 | @ApiProperty({ 38 | example: 'Image Url', 39 | required: false, 40 | minimum: 1, 41 | maximum: 128, 42 | description: '', 43 | }) 44 | @IsString() 45 | @MinLength(1) 46 | @MaxLength(1200) 47 | description: string; 48 | 49 | @ApiProperty({ 50 | example: 'Website Url', 51 | required: false, 52 | minimum: 1, 53 | maximum: 128, 54 | description: '', 55 | }) 56 | @IsString() 57 | @MinLength(1) 58 | @MaxLength(1200) 59 | @IsOptional() 60 | website: string; 61 | 62 | @ApiProperty({ 63 | example: 'Twitter Url', 64 | required: false, 65 | minimum: 1, 66 | maximum: 128, 67 | description: '', 68 | }) 69 | @IsString() 70 | @MinLength(1) 71 | @MaxLength(1200) 72 | @IsOptional() 73 | twitter: string; 74 | 75 | @ApiProperty({ 76 | example: 'Discord Url', 77 | required: false, 78 | minimum: 1, 79 | maximum: 128, 80 | description: '', 81 | }) 82 | @IsString() 83 | @MinLength(1) 84 | @MaxLength(1200) 85 | @IsOptional() 86 | discord: string; 87 | 88 | @ApiProperty({ 89 | example: [ 90 | '29cae3cba151f520b23d8649e9bb5917d9f90227536fe356689a6c76885f582ai0', 91 | '3de0c304b7542eddf4638a08cf02c89fc98dffa1170f3369fc44b41f25bf8442i0', 92 | ], 93 | required: false, 94 | minimum: 1, 95 | maximum: 128, 96 | description: '', 97 | }) 98 | @IsArray() 99 | @IsString({ each: true }) 100 | @ArrayMinSize(1) 101 | inscriptionIds: string[]; 102 | } 103 | -------------------------------------------------------------------------------- /src/collection/collection.type.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { PageDto } from '@src/common/pagination/pagination.types'; 3 | 4 | export class CollectionId { 5 | @ApiProperty({ description: `Inscription Id` }) 6 | inscriptionId: string; 7 | } 8 | 9 | export class InscriptionIdData { 10 | @ApiProperty({ isArray: true, description: `Inscription id data` }) 11 | data: CollectionId[]; 12 | } 13 | 14 | export class BasicCollectionInfo { 15 | @ApiProperty({ description: `Collection name` }) 16 | name: string; 17 | 18 | @ApiProperty({ description: `Collection description` }) 19 | description: string; 20 | 21 | @ApiProperty({ description: `Collection Image Url` }) 22 | imgUrl: string; 23 | } 24 | 25 | export class DiscoverCollection extends BasicCollectionInfo { 26 | @ApiProperty({ description: `Some inscription ids of this collection` }) 27 | inscriptions: InscriptionIdData; 28 | 29 | @ApiProperty({ description: `Number of inscriptions in this collection` }) 30 | itemCount: number; 31 | 32 | @ApiProperty({ description: `Floor price in this collection` }) 33 | floorPrice: number; 34 | } 35 | 36 | export class PriceInscriptionInfo { 37 | @ApiProperty({ description: `Inscription Id` }) 38 | inscriptionId: string; 39 | 40 | @ApiProperty({ description: `Inscription price` }) 41 | price?: string; 42 | 43 | @ApiProperty({ description: `Inscription owner address` }) 44 | userAddress?: string; 45 | } 46 | 47 | export class CollectionInscriptions extends BasicCollectionInfo { 48 | @ApiProperty({ description: `Inscription information` }) 49 | inscriptions: PageDto; 50 | 51 | @ApiProperty({ description: `Collection website url` }) 52 | website?: string; 53 | 54 | @ApiProperty({ description: `Collection website url` }) 55 | twitter?: string; 56 | 57 | @ApiProperty({ description: `Collection website url` }) 58 | discord?: string; 59 | } 60 | 61 | export class PopularCollection extends BasicCollectionInfo { 62 | @ApiProperty({ description: `Floor price in this collection` }) 63 | floorPrice: number; 64 | } 65 | 66 | export class CollectionDetailedInfo { 67 | @ApiProperty({ description: `Collection total count` }) 68 | totalCount: number; 69 | 70 | @ApiProperty({ description: `Collection listed item count` }) 71 | listedItems: number; 72 | 73 | @ApiProperty({ description: `Collection floor price` }) 74 | floorPrice: number; 75 | 76 | @ApiProperty({ description: `Collection total sales` }) 77 | totalSales: number; 78 | } 79 | -------------------------------------------------------------------------------- /src/common/helpers/api-response.helper.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { HttpStatus, Type } from '@nestjs/common'; 3 | import { ApiResponseOptions } from '@nestjs/swagger'; 4 | 5 | export class ApiResponseHelper { 6 | static success( 7 | type: Type | Function | [Function] | string, 8 | httpCode: number = HttpStatus.OK, 9 | ): ApiResponseOptions { 10 | return { status: httpCode, type, description: 'Successful operation' }; 11 | } 12 | 13 | static successWithExample( 14 | example: any, 15 | httpCode: number = HttpStatus.OK, 16 | ): ApiResponseOptions { 17 | return { 18 | status: httpCode, 19 | schema: { example }, 20 | description: 'Successful operation', 21 | }; 22 | } 23 | 24 | static created( 25 | type: Type | Function | [Function] | string = '', 26 | ): ApiResponseOptions { 27 | return { 28 | status: HttpStatus.CREATED, 29 | type, 30 | description: 'Successfully created', 31 | }; 32 | } 33 | 34 | static validationError(errorMessage: string): ApiResponseOptions { 35 | const schemaExample = { 36 | statusCode: HttpStatus.BAD_REQUEST, 37 | message: [errorMessage], 38 | error: 'Bad Request', 39 | }; 40 | 41 | return { 42 | status: HttpStatus.BAD_REQUEST, 43 | description: 'Validation error', 44 | schema: { example: schemaExample }, 45 | }; 46 | } 47 | 48 | static validationErrors(errorMessage: string[]): ApiResponseOptions { 49 | const schemaExample = { 50 | statusCode: HttpStatus.BAD_REQUEST, 51 | message: [...errorMessage], 52 | error: 'Bad Request', 53 | }; 54 | 55 | return { 56 | status: HttpStatus.BAD_REQUEST, 57 | description: 'Validation error', 58 | schema: { example: schemaExample }, 59 | }; 60 | } 61 | 62 | static unauthorized(): ApiResponseOptions { 63 | const schemaExample = { 64 | statusCode: HttpStatus.UNAUTHORIZED, 65 | error: 'Unauthorized', 66 | }; 67 | 68 | return { 69 | status: HttpStatus.UNAUTHORIZED, 70 | description: 'Unauthorized', 71 | schema: { example: schemaExample }, 72 | }; 73 | } 74 | 75 | static notFound(notFoundError = 'Not found'): ApiResponseOptions { 76 | const schemaExample = { 77 | statusCode: HttpStatus.NOT_FOUND, 78 | error: notFoundError, 79 | }; 80 | 81 | return { 82 | status: HttpStatus.NOT_FOUND, 83 | description: 'Successfully created', 84 | schema: { example: schemaExample }, 85 | }; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; 6 | import { ScheduleModule } from '@nestjs/schedule'; 7 | 8 | import { AppController } from './app.controller'; 9 | import { EnvHelper } from './common/helpers/env.helper'; 10 | import appConfig from './config/app.config'; 11 | import databaseConfig from './config/database.config'; 12 | import jwtConfig from './config/jwt.config'; 13 | import { validate } from './common/validators/env.validator'; 14 | import { UserModule } from './user/user.module'; 15 | import { AuthModule } from './auth/auth.module'; 16 | import { CollectionModule } from './collection/collection.module'; 17 | import { InscriptionService } from './inscription/inscription.service'; 18 | import { InscriptionModule } from './inscription/inscription.module'; 19 | import { PsbtModule } from './psbt/psbt.module'; 20 | import { SwapOfferModule } from './swap-offer/swap-offer.module'; 21 | import psbtConfig from './config/psbt.config'; 22 | import { FileModule } from './file/file.module'; 23 | import { SearchModule } from './search/search.module'; 24 | import fileConfig from './config/file.config'; 25 | import { CacheModule } from '@nestjs/cache-manager'; 26 | import * as redisStore from 'cache-manager-redis-store'; 27 | import redisConfig from './config/redis.config'; 28 | 29 | EnvHelper.verifyNodeEnv(); 30 | 31 | @Module({ 32 | imports: [ 33 | ScheduleModule.forRoot(), 34 | ConfigModule.forRoot({ 35 | envFilePath: EnvHelper.getEnvFilePath(), 36 | isGlobal: true, 37 | load: [ 38 | appConfig, 39 | databaseConfig, 40 | jwtConfig, 41 | psbtConfig, 42 | fileConfig, 43 | redisConfig, 44 | ], 45 | validate: validate, 46 | }), 47 | TypeOrmModule.forRootAsync({ 48 | imports: [ConfigModule], 49 | useFactory: async (configService: ConfigService) => { 50 | const config = configService.get('databaseConfig'); 51 | return { 52 | ...config, 53 | namingStrategy: new SnakeNamingStrategy(), 54 | autoLoadEntities: true, 55 | }; 56 | }, 57 | inject: [ConfigService], 58 | }), 59 | CacheModule.registerAsync({ 60 | isGlobal: true, 61 | imports: [ConfigModule], 62 | useFactory: async (configService: ConfigService) => { 63 | const config = configService.get('redisConfig'); 64 | return { 65 | ...config, 66 | isGlobal: true, 67 | ttl: 300, 68 | store: redisStore, 69 | no_ready_check: true, // new property 70 | }; 71 | }, 72 | inject: [ConfigService], 73 | }), 74 | UserModule, 75 | AuthModule, 76 | CollectionModule, 77 | InscriptionModule, 78 | PsbtModule, 79 | SwapOfferModule, 80 | FileModule, 81 | SearchModule, 82 | ], 83 | controllers: [AppController], 84 | providers: [InscriptionService], 85 | }) 86 | export class AppModule {} 87 | -------------------------------------------------------------------------------- /src/common/validators/env.validator.ts: -------------------------------------------------------------------------------- 1 | import { plainToInstance } from 'class-transformer'; 2 | import { 3 | IsEnum, 4 | IsIn, 5 | IsInt, 6 | IsString, 7 | Min, 8 | MinLength, 9 | validateSync, 10 | } from 'class-validator'; 11 | 12 | export enum Environment { 13 | Development = 'development', 14 | Production = 'production', 15 | Test = 'test', 16 | } 17 | 18 | class EnvironmentVariables { 19 | @IsEnum(Environment) 20 | NODE_ENV: Environment; 21 | 22 | @IsString() 23 | @MinLength(1) 24 | TYPEORM_HOST: string; 25 | 26 | @IsInt() 27 | @Min(1) 28 | TYPEORM_PORT: number; 29 | 30 | @IsString() 31 | @MinLength(1) 32 | TYPEORM_PASSWORD: string; 33 | 34 | @IsString() 35 | @MinLength(1) 36 | TYPEORM_DATABASE: string; 37 | 38 | @IsString() 39 | @MinLength(1) 40 | TYPEORM_USERNAME: string; 41 | 42 | @IsString() 43 | @MinLength(1) 44 | TYPEORM_CONNECTION: string; 45 | 46 | @IsString() 47 | @MinLength(1) 48 | TYPEORM_MIGRATIONS: string; 49 | 50 | @IsString() 51 | @MinLength(1) 52 | TYPEORM_MIGRATIONS_DIR: string; 53 | 54 | @IsString() 55 | @MinLength(1) 56 | TYPEORM_LOGGING: string; 57 | 58 | @IsInt() 59 | @Min(10) 60 | TYPEORM_POOL_SIZE: number; 61 | 62 | @IsIn(['true', 'false']) 63 | POSTGRESQL_TLS: 'true' | 'false'; 64 | 65 | @IsString() 66 | @MinLength(64) 67 | JWT_SECRET: string; 68 | 69 | @IsString() 70 | @MinLength(1) 71 | JWT_REFRESH_TOKEN_COOKIE_DOMAIN: string; 72 | 73 | @IsString() 74 | @MinLength(1) 75 | JWT_REFRESH_TOKEN_DURATION_DAYS: string; 76 | 77 | @IsString() 78 | @MinLength(1) 79 | JWT_REFRESH_TOKEN_MAX_SESSIONS: string; 80 | 81 | @IsString() 82 | @MinLength(1) 83 | JWT_ACCESS_TOKEN_DURATION_MINUTES: string; 84 | 85 | @IsString() 86 | @IsIn(['true', 'false']) 87 | JWT_REFRESH_TOKEN_COOKIE_SECURE: 'true' | 'false'; 88 | 89 | @IsString() 90 | @IsIn(['true', 'false']) 91 | JWT_REFRESH_TOKEN_COOKIE_HTTPONLY: 'true' | 'false'; 92 | 93 | @IsInt() 94 | @Min(0) 95 | FEE_PERCENT: number; 96 | 97 | @IsString() 98 | @MinLength(1) 99 | ADMIN_WALLET_ADDRESS: string; 100 | 101 | @IsString() 102 | @MinLength(1) 103 | @IsIn(['testnet', 'mainnet']) 104 | NETWORK: 'testnet' | 'mainnet'; 105 | 106 | @IsString() 107 | @MinLength(1) 108 | AWS_S3_ACCESS_KEY: string; 109 | 110 | @IsString() 111 | @MinLength(1) 112 | AWS_S3_KEY_SECRET: string; 113 | 114 | @IsString() 115 | @MinLength(1) 116 | AWS_S3_BUCKET: string; 117 | 118 | @IsString() 119 | @MinLength(1) 120 | UNISAT_KEY: string; 121 | 122 | @IsString() 123 | @MinLength(1) 124 | REDIS_HOST: string; 125 | 126 | @IsInt() 127 | @Min(1) 128 | REDIS_PORT: number; 129 | 130 | @IsString() 131 | @MinLength(1) 132 | REDIS_USERNAME: string; 133 | 134 | @IsString() 135 | @MinLength(1) 136 | REDIS_PASSWORD: string; 137 | } 138 | 139 | export function validate(config: Record) { 140 | const validatedConfig = plainToInstance(EnvironmentVariables, config, { 141 | enableImplicitConversion: true, 142 | }); 143 | 144 | const errors = validateSync(validatedConfig, { 145 | skipMissingProperties: false, 146 | }); 147 | 148 | if (errors.length > 0) { 149 | throw new Error(errors.toString()); 150 | } 151 | 152 | return validatedConfig; 153 | } 154 | -------------------------------------------------------------------------------- /src/swap-offer/swap-offer.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateDateColumn, 3 | Column, 4 | DeleteDateColumn, 5 | Entity, 6 | PrimaryGeneratedColumn, 7 | UpdateDateColumn, 8 | ManyToOne, 9 | OneToMany, 10 | } from 'typeorm'; 11 | import { ApiProperty } from '@nestjs/swagger'; 12 | import { Exclude } from 'class-transformer'; 13 | import { BuyerSwapInscription } from './buyer-swap-inscription.entity'; 14 | import { SellerSwapInscription } from './seller-swap-inscription.entity'; 15 | import { User } from '@src/user/user.entity'; 16 | 17 | export enum OfferStatus { 18 | CREATED = 'created', // created the psbt 19 | SIGNED = 'signed', //buy signed the psbt 20 | ACCEPTED = 'accepted', // owner signed the psbt 21 | PUSHED = 'pushed', // combine and pushed 22 | FAILED = 'failed', // failed when push transaction 23 | CANCELED = 'canceled', // canceled by owner 24 | EXPIRED = 'expired', // expired 25 | PENDING = 'pending', // waiting for push tx 26 | } 27 | 28 | @Entity('swap_offer') 29 | export class SwapOffer { 30 | @Exclude({ toPlainOnly: true }) 31 | @PrimaryGeneratedColumn({ name: 'id' }) 32 | id: number; 33 | 34 | @ApiProperty({ description: `Unique uuid`, maximum: 36 }) 35 | @Column({ type: 'varchar', nullable: false, length: 36 }) 36 | uuid: string; 37 | 38 | @ApiProperty({ description: `Buy now price`, maximum: 36 }) 39 | @Column({ type: 'float', nullable: false }) 40 | price: number; 41 | 42 | @ApiProperty({ description: 'Offer Status', maximum: 255, required: true }) 43 | @Column({ type: 'enum', enum: OfferStatus, nullable: false }) 44 | status: OfferStatus; 45 | 46 | @ApiProperty({ description: `psbt`, maximum: 5000 }) 47 | @Column({ 48 | type: 'varchar', 49 | nullable: false, 50 | length: 5000, 51 | default: OfferStatus.CREATED, 52 | }) 53 | psbt: string; 54 | 55 | @ApiProperty({ description: `Seller signed psbt`, maximum: 5000 }) 56 | @Column({ type: 'varchar', nullable: true, length: 5000 }) 57 | sellerSignedPsbt: string; 58 | 59 | @ApiProperty({ description: `Buyer signed psbt`, maximum: 5000 }) 60 | @Column({ type: 'varchar', nullable: true, length: 5000 }) 61 | buyerSignedPsbt: string; 62 | 63 | @ApiProperty({ description: `Transaction id`, maximum: 100 }) 64 | @Column({ type: 'varchar', nullable: true, length: 100 }) 65 | txId: string; 66 | 67 | @Column({ 68 | type: 'timestamp', 69 | nullable: false, 70 | default: new Date(), 71 | }) 72 | expiredAt: Date; 73 | 74 | @ApiProperty({ 75 | description: 'Date when the user was created', 76 | required: true, 77 | }) 78 | @CreateDateColumn() 79 | createdAt: Date; 80 | 81 | @ApiProperty({ 82 | description: 'Date when user was updated the last time', 83 | required: false, 84 | }) 85 | @UpdateDateColumn() 86 | updatedAt: Date; 87 | 88 | @Exclude({ toPlainOnly: true }) 89 | @DeleteDateColumn() 90 | deletedAt: Date; 91 | 92 | @OneToMany( 93 | () => BuyerSwapInscription, 94 | (buyerSwapInscription) => buyerSwapInscription.swapOffer, 95 | ) 96 | buyerSwapInscription: BuyerSwapInscription[]; 97 | 98 | @OneToMany( 99 | () => SellerSwapInscription, 100 | (sellerSwapInscription) => sellerSwapInscription.swapOffer, 101 | ) 102 | sellerSwapInscription: SellerSwapInscription[]; 103 | 104 | @ManyToOne(() => User, (user) => user.buyerswapOffer) 105 | buyer: User; 106 | 107 | @ManyToOne(() => User, (user) => user.sellerswapOffer) 108 | seller: User; 109 | } 110 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Inject, 4 | Injectable, 5 | forwardRef, 6 | } from '@nestjs/common'; 7 | import { JwtService } from '@nestjs/jwt'; 8 | import { generateSlug } from 'random-word-slugs'; 9 | import { Verifier } from 'bip322-js'; 10 | 11 | import { User } from '@src/user/user.entity'; 12 | import { UserService } from '@src/user/user.service'; 13 | import { AccessTokenInterface } from './auth.type'; 14 | import { LoginUserDto } from './dto/login-user.dto'; 15 | import { SignMessageRepository } from './sign-message.repository'; 16 | import { ConfigService } from '@nestjs/config'; 17 | 18 | @Injectable() 19 | export class AuthService { 20 | private network: string; 21 | 22 | constructor( 23 | @Inject(forwardRef(() => UserService)) 24 | private readonly userService: UserService, 25 | private readonly jwtService: JwtService, 26 | private readonly signMessageRepository: SignMessageRepository, 27 | private readonly configService: ConfigService, 28 | ) { 29 | this.network = this.configService.get('psbtConfig.network'); 30 | } 31 | 32 | async login(user: Partial) { 33 | const accessToken = await this.createAccessToken(user); 34 | 35 | return { 36 | accessToken, 37 | }; 38 | } 39 | 40 | async validateUser(body: LoginUserDto): Promise | null> { 41 | if (this.network === 'mainnet' && !body.address.startsWith('bc1p')) 42 | throw new BadRequestException('Wrong network'); 43 | if (this.network !== 'mainnet' && !body.address.startsWith('tb1p')) 44 | throw new BadRequestException('Wrong network'); 45 | 46 | const message = await this.getSignMessage(body.address); 47 | 48 | await this.generateSignMessage(body.address); 49 | 50 | try { 51 | const validity = Verifier.verifySignature( 52 | body.address, 53 | message, 54 | body.signature, 55 | ); 56 | 57 | if (validity === false) 58 | throw new BadRequestException('The signature is invalid'); 59 | } catch (error) { 60 | throw new BadRequestException('The signature is invalid'); 61 | } 62 | 63 | const user = await this.userService.findByAddress(body.address); 64 | if (user) 65 | return { 66 | address: user.address, 67 | uuid: user.uuid, 68 | role: user.role, 69 | }; 70 | const savedUser = await this.userService.create(body); 71 | 72 | return { 73 | address: savedUser.address, 74 | uuid: savedUser.uuid, 75 | role: savedUser.role, 76 | }; 77 | } 78 | 79 | async createAccessToken(user: Partial): Promise { 80 | const payload: AccessTokenInterface = { 81 | address: user.address, 82 | uuid: user.uuid, 83 | role: user.role, 84 | }; 85 | 86 | return this.jwtService.signAsync(payload); 87 | } 88 | 89 | async getUser(uuid: string): Promise { 90 | return this.userService.findByUuid(uuid); 91 | } 92 | 93 | async generateSignMessage(address: string): Promise<{ message: string }> { 94 | const signMessage = await this.signMessageRepository.findOne({ 95 | where: { address }, 96 | }); 97 | const message = generateSlug(5); 98 | 99 | if (!signMessage) 100 | await this.signMessageRepository.save({ address, message }); 101 | else await this.signMessageRepository.update({ address }, { message }); 102 | 103 | return { message }; 104 | } 105 | 106 | async getSignMessage(address: string): Promise { 107 | const signMessage = await this.signMessageRepository.findOne({ 108 | where: { address }, 109 | }); 110 | if (!signMessage) 111 | throw new BadRequestException('Can not find sign message'); 112 | 113 | return signMessage.message; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/collection/collection.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from '@nestjs/common'; 2 | 3 | import { InscriptionService } from '@src/inscription/inscription.service'; 4 | import { PageOptionsDto } from '@src/common/pagination/pagination.types'; 5 | import { CollectionRepository } from './colletion.repository'; 6 | import { CreateCollectionDto } from './dto/create-collection.dto'; 7 | import { Collection } from './collection.entity'; 8 | import { PsbtService } from '@src/psbt/psbt.service'; 9 | 10 | @Injectable() 11 | export class CollectionService { 12 | constructor( 13 | private collectionRepository: CollectionRepository, 14 | private inscriptionService: InscriptionService, 15 | private psbtService: PsbtService, 16 | ) { 17 | this.addTempCollection(); 18 | } 19 | 20 | async addTempCollection() { 21 | const count = await this.collectionRepository.count(); 22 | 23 | if (count > 0) return; 24 | 25 | const collectionEntity = this.collectionRepository.create(); 26 | this.collectionRepository.save(collectionEntity); 27 | } 28 | 29 | async createCollection(body: CreateCollectionDto) { 30 | const collection: Partial = { 31 | ...body, 32 | }; 33 | const savedCollection = await this.collectionRepository.save(collection); 34 | 35 | const saveInscriptions = body.inscriptionIds.map( 36 | async (inscriptionId: string) => { 37 | return this.inscriptionService.createInscription( 38 | savedCollection.id, 39 | inscriptionId, 40 | ); 41 | }, 42 | ); 43 | await Promise.all(saveInscriptions); 44 | 45 | return this.collectionRepository.findOne({ 46 | where: { 47 | id: savedCollection.id, 48 | }, 49 | relations: { 50 | inscription: true, 51 | }, 52 | }); 53 | } 54 | 55 | async getCollectionInfo( 56 | collectionName: string, 57 | pageOptionsDto: PageOptionsDto, 58 | ): Promise { 59 | const collection = await this.collectionRepository.findOne({ 60 | where: { 61 | name: collectionName, 62 | }, 63 | select: { 64 | id: true, 65 | imgUrl: true, 66 | name: true, 67 | description: true, 68 | website: true, 69 | discord: true, 70 | twitter: true, 71 | }, 72 | }); 73 | 74 | if (!collection) 75 | throw new BadRequestException('Can not find that collection'); 76 | 77 | const inscriptions = await this.inscriptionService.getPaginatedInscriptions( 78 | collection.id, 79 | pageOptionsDto, 80 | ); 81 | 82 | const inscriptionDatas = await Promise.all( 83 | inscriptions.data.map((inscriptionId) => 84 | this.psbtService.getInscriptionWithUtxo(inscriptionId), 85 | ), 86 | ); 87 | 88 | const inscriptionIds = inscriptions.data.map( 89 | (inscriptionId) => inscriptionId, 90 | ); 91 | 92 | const batchInscriptionInfo = 93 | await this.psbtService.getBatchInscriptionInfoBIS(inscriptionIds); 94 | 95 | collection['inscriptions'] = { 96 | data: Object.values(batchInscriptionInfo), 97 | meta: inscriptions.meta, 98 | }; 99 | 100 | return collection; 101 | } 102 | 103 | async search(keyWord: string): Promise[]> { 104 | const searchResult = await this.collectionRepository 105 | .createQueryBuilder('collection') 106 | .where('LOWER(name) LIKE LOWER(:search)', { 107 | search: `%${keyWord}%`, 108 | }) 109 | .orderBy('updated_at', 'DESC') 110 | .getRawAndEntities(); 111 | 112 | const collections = searchResult.entities.map((collection) => { 113 | return { 114 | id: collection.id, 115 | name: collection.name, 116 | imgUrl: collection.imgUrl, 117 | }; 118 | }); 119 | 120 | return collections; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@nestjs/cache-manager": "^2.2.1", 25 | "@nestjs/common": "^9.0.0", 26 | "@nestjs/config": "^2.2.0", 27 | "@nestjs/core": "^9.0.0", 28 | "@nestjs/jwt": "^9.0.0", 29 | "@nestjs/passport": "^9.0.0", 30 | "@nestjs/platform-express": "^9.0.0", 31 | "@nestjs/schedule": "^3.0.2", 32 | "@nestjs/swagger": "^6.1.3", 33 | "@nestjs/testing": "^9.0.0", 34 | "@nestjs/typeorm": "^9.0.1", 35 | "@sentry/node": "^7.19.0", 36 | "@types/passport-jwt": "^3.0.7", 37 | "aws-sdk": "^2.1448.0", 38 | "axios": "^1.4.0", 39 | "bcrypt": "^5.1.0", 40 | "bip322-js": "^1.1.0", 41 | "bitcoin-address-validation": "^2.2.3", 42 | "bitcoinjs-lib": "^6.1.3", 43 | "cache-manager": "^5.4.0", 44 | "cache-manager-redis-store": "^2.0.0", 45 | "class-transformer": "^0.5.1", 46 | "class-validator": "^0.13.2", 47 | "luxon": "^3.1.0", 48 | "nestjs-real-ip": "^2.2.0", 49 | "passport": "^0.6.0", 50 | "passport-jwt": "^4.0.0", 51 | "passport-local": "^1.0.0", 52 | "pg": "^8.8.0", 53 | "random-word-slugs": "^0.1.7", 54 | "reflect-metadata": "^0.1.13", 55 | "rimraf": "^3.0.2", 56 | "rxjs": "^7.2.0", 57 | "tiny-secp256k1": "^2.2.3", 58 | "typeorm": "^0.3.10", 59 | "typeorm-naming-strategies": "^4.1.0" 60 | }, 61 | "devDependencies": { 62 | "@nestjs/cli": "^9.0.0", 63 | "@nestjs/schematics": "^9.0.0", 64 | "@types/cache-manager-redis-store": "^2.0.4", 65 | "@types/cron": "^2.4.0", 66 | "@types/express": "^4.17.13", 67 | "@types/jest": "28.1.8", 68 | "@types/multer": "^1.4.7", 69 | "@types/node": "^16.0.0", 70 | "@types/supertest": "^2.0.11", 71 | "@typescript-eslint/eslint-plugin": "^5.0.0", 72 | "@typescript-eslint/parser": "^5.0.0", 73 | "eslint": "^8.0.1", 74 | "eslint-config-prettier": "^8.3.0", 75 | "eslint-plugin-prettier": "^4.0.0", 76 | "jest": "28.1.3", 77 | "prettier": "^2.3.2", 78 | "source-map-support": "^0.5.20", 79 | "supertest": "^6.1.3", 80 | "ts-jest": "28.0.8", 81 | "ts-loader": "^9.2.3", 82 | "ts-node": "^10.0.0", 83 | "tsconfig-paths": "4.1.0", 84 | "typescript": "^4.7.4" 85 | }, 86 | "jest": { 87 | "moduleFileExtensions": [ 88 | "js", 89 | "json", 90 | "ts" 91 | ], 92 | "rootDir": "src", 93 | "testRegex": ".*\\.spec\\.ts$", 94 | "transform": { 95 | "^.+\\.(t|j)s$": "ts-jest" 96 | }, 97 | "collectCoverageFrom": [ 98 | "**/*.(t|j)s" 99 | ], 100 | "coverageDirectory": "../coverage", 101 | "testEnvironment": "node" 102 | }, 103 | "lint-staged": { 104 | "**/*.{js,jsx,ts,tsx}": [ 105 | "eslint --fix", 106 | "prettier --config ./.prettierrc.js --write" 107 | ], 108 | "**/*.{css,scss,md,html,json}": [ 109 | "prettier --config ./.prettierrc.js --write" 110 | ] 111 | }, 112 | "config": { 113 | "commitizen": { 114 | "path": "cz-conventional-changelog" 115 | } 116 | }, 117 | "engines": { 118 | "node": ">=18.12.1" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/search/search.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { validate, getAddressInfo } from 'bitcoin-address-validation'; 3 | 4 | import { CollectionService } from '@src/collection/collection.service'; 5 | import { InscriptionService } from '@src/inscription/inscription.service'; 6 | import { PsbtService } from '@src/psbt/psbt.service'; 7 | import { UserService } from '@src/user/user.service'; 8 | import { ConfigService } from '@nestjs/config'; 9 | import { CACHE_MANAGER } from '@nestjs/cache-manager'; 10 | import { Cache } from 'cache-manager'; 11 | 12 | export const AllowedContentTypes = [ 13 | 'image/svg+xml', 14 | 'image/apng', 15 | 'image/avif', 16 | 'image/gif', 17 | 'image/jpeg', 18 | 'image/png', 19 | 'image/webp', 20 | 'text/html', 21 | 'text/html;charset=utf-8', 22 | ]; 23 | 24 | @Injectable() 25 | export class SearchService { 26 | private network; 27 | 28 | constructor( 29 | private readonly inscriptionService: InscriptionService, 30 | private readonly collectionService: CollectionService, 31 | private readonly userService: UserService, 32 | private readonly psbtService: PsbtService, 33 | private readonly configService: ConfigService, 34 | @Inject(CACHE_MANAGER) private cacheService: Cache, 35 | ) { 36 | this.network = this.configService.get('psbtConfig.network'); 37 | } 38 | 39 | async search(keyWord: string) { 40 | const isAddress = validate(keyWord, this.network); 41 | 42 | if (isAddress) { 43 | const [inscription, collection, address] = await Promise.all([ 44 | {}, 45 | this.searchCollection(keyWord), 46 | [{ address: keyWord }], 47 | ]); 48 | 49 | return { inscription, collection, address }; 50 | } else { 51 | const [inscription, collection, address] = await Promise.all([ 52 | this.searchInscription(keyWord), 53 | this.searchCollection(keyWord), 54 | {}, 55 | ]); 56 | 57 | return { inscription, collection, address }; 58 | } 59 | } 60 | 61 | async searchCollection(keyword: string) { 62 | return this.collectionService.search(keyword); 63 | } 64 | 65 | async searchInscription(inscriptionId: string) { 66 | try { 67 | const [inscription, inscriptionUtxo] = await Promise.all([ 68 | this.inscriptionService.search(inscriptionId), 69 | this.psbtService.getInscriptionWithUtxo(inscriptionId), 70 | ]); 71 | 72 | const contentType = AllowedContentTypes.find( 73 | (contentType) => contentType === inscriptionUtxo.contentType, 74 | ); 75 | 76 | if (contentType) return { ...inscriptionUtxo, ...inscription }; 77 | return []; 78 | } catch (_error) { 79 | return {}; 80 | } 81 | } 82 | 83 | async searchByAddress(address: string) { 84 | try { 85 | const cachedData = await this.cacheService.get(address); 86 | if (cachedData) return cachedData; 87 | 88 | const inscriptions = await this.psbtService.getInscriptionByAddress( 89 | address, 90 | ); 91 | 92 | const allowInscriptions = inscriptions.filter((inscription) => 93 | AllowedContentTypes.find( 94 | (contentType) => contentType === inscription.contentType, 95 | ), 96 | ); 97 | 98 | const inscriptionIds = allowInscriptions.map( 99 | (inscription) => inscription.inscriptionId, 100 | ); 101 | const collectionInfos = 102 | await this.inscriptionService.findInscriptionByIdsWithCollection( 103 | inscriptionIds, 104 | ); 105 | 106 | const addressInscriptions = allowInscriptions.map((inscription) => { 107 | const collection = collectionInfos.find( 108 | (collectionInfo) => 109 | collectionInfo.inscriptionId === inscription.inscriptionId, 110 | ); 111 | 112 | if (!collection) return { ...inscription }; 113 | return { ...inscription, collection: { ...collection.collection } }; 114 | }); 115 | 116 | await this.cacheService.set(address, addressInscriptions); 117 | return addressInscriptions; 118 | } catch (error) { 119 | return {}; 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ordinal Marketplace Backend 2 | 3 | ## Description 4 | 5 | A robust backend system for an Ordinal NFT marketplace built with NestJS and PostgreSQL. This platform enables users to list Bitcoin Ordinal inscriptions, make offers, engage in discussions through comments, and participate in both global and direct messaging. 6 | 7 | ## Key Features 8 | 9 | - **Inscription Management** 10 | - List and manage Ordinal inscriptions 11 | - Associate inscriptions with collections 12 | - Track inscription ownership and transfers 13 | 14 | - **Trading System** 15 | - Create and manage swap offers 16 | - Support for buyer and seller interactions 17 | - PSBT (Partially Signed Bitcoin Transaction) handling 18 | - Secure transaction processing 19 | 20 | - **Collection Management** 21 | - Create and manage collections 22 | - Associate inscriptions with collections 23 | - Collection metadata management (name, description, social links) 24 | 25 | - **Communication System** 26 | - Global chat for community discussions 27 | - Direct messaging between users 28 | - Comment system for inscriptions 29 | - Real-time messaging using WebSocket 30 | - Message persistence in PostgreSQL 31 | - Redis pub/sub for message distribution 32 | 33 | - **User System** 34 | - Wallet integration (UniSat, Hiro, Xverse) 35 | - User authentication via message signing 36 | - Role-based access control (customer, admin) 37 | 38 | - **Performance Optimization** 39 | - LavinMQ integration for message queuing 40 | - Redis caching for high-performance data access 41 | - Optimized for handling large request volumes 42 | 43 | ## Technical Stack 44 | 45 | - **Framework**: NestJS 46 | - **Database**: PostgreSQL with TypeORM 47 | - **Caching**: Redis 48 | - **Message Queue**: LavinMQ 49 | - **Authentication**: Bitcoin wallet signature verification 50 | - **Bitcoin Integration**: BitcoinJS, BIP322 51 | 52 | ## Project Structure 53 | 54 | ``` 55 | src/ 56 | ├── auth/ # Authentication and authorization 57 | ├── collection/ # Collection management 58 | ├── inscription/ # Ordinal inscription handling 59 | ├── psbt/ # Bitcoin transaction handling 60 | ├── swap-offer/ # Trading system 61 | ├── user/ # User management 62 | └── common/ # Shared utilities and types 63 | ``` 64 | 65 | ## Database Schema 66 | 67 | Key entities include: 68 | - User 69 | - Collection 70 | - Inscription 71 | - SwapOffer 72 | - BuyerSwapInscription 73 | - SellerSwapInscription 74 | - SignMessage 75 | 76 | ## Local Development 77 | 78 | ### Prerequisites 79 | 80 | - Node.js >= 18.12.1 81 | - Docker and Docker Compose 82 | - PostgreSQL 83 | - Redis 84 | - LavinMQ 85 | 86 | ### Setup with Docker 87 | 88 | 1. Install Docker and Docker Compose 89 | 2. Clone the repository 90 | 3. Run the development environment: 91 | 92 | ```bash 93 | # Start the containers 94 | docker-compose up -d 95 | 96 | # Install dependencies 97 | npm install 98 | 99 | # Start the development server 100 | npm run start:dev 101 | ``` 102 | 103 | Access the API documentation at `http://localhost:3005/docs` 104 | 105 | ### Database Connection 106 | 107 | Connect to the local Postgres Database: 108 | 109 | ```bash 110 | psql -h db -U postgres postgres 111 | ``` 112 | Password: `postgres` 113 | 114 | ## Available Scripts 115 | 116 | ```bash 117 | # Development 118 | npm run start:dev 119 | 120 | # Production build 121 | npm run build 122 | npm run start:prod 123 | 124 | # Testing 125 | npm run test 126 | npm run test:e2e 127 | ``` 128 | 129 | ## API Documentation 130 | 131 | The API documentation is available through Swagger UI at `/docs` endpoint when running the server. 132 | 133 | ## Performance Considerations 134 | 135 | - Uses Redis caching for frequently accessed data 136 | - LavinMQ for handling asynchronous tasks and large request volumes 137 | - Optimized database queries and indexing 138 | - Efficient PSBT handling for Bitcoin transactions 139 | 140 | ## Security Features 141 | 142 | - Wallet-based authentication 143 | - Message signing verification 144 | - Role-based access control 145 | - Secure PSBT handling 146 | - Environment-based configuration 147 | -------------------------------------------------------------------------------- /src/swap-offer/swap-offer.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpStatus, 6 | Param, 7 | Post, 8 | Query, 9 | Request, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; 13 | 14 | import { JwtAuthGuard } from '@src/auth/guards/jwt-auth.guard'; 15 | import { 16 | PageDto, 17 | PageOptionsDto, 18 | } from '@src/common/pagination/pagination.types'; 19 | import { ApiResponseHelper } from '@src/common/helpers/api-response.helper'; 20 | import { BuyerSignPsbtDto } from './dto/buyer-sign-psbt.dto'; 21 | import { SellerSignPsbtDto } from './dto/seller-sign-psbt.dto'; 22 | import { GenerateSwapPsbtDto } from './dto/generate-swap-psbt.dto'; 23 | import { CancelSwapOfferDto } from './dto/cancel-swap-offer.dto'; 24 | import { SwapOfferService } from './swap-offer.service'; 25 | import { GeneratePbst, PushTxResult, SignPsbtResult } from './swap-offer.type'; 26 | import { SwapOffer } from './swap-offer.entity'; 27 | import { GetOfferDto } from './dto/get-offer.dto'; 28 | import { GetUserHistoryDto } from './dto/get-user-history.dto'; 29 | 30 | @Controller('swap-offer') 31 | export class SwapOfferController { 32 | constructor(private swapOfferService: SwapOfferService) {} 33 | 34 | @ApiBearerAuth() 35 | @UseGuards(JwtAuthGuard) 36 | @ApiOperation({ 37 | description: `Generate swap psbt`, 38 | tags: ['Swap offer'], 39 | }) 40 | @ApiResponse(ApiResponseHelper.success(GeneratePbst, HttpStatus.CREATED)) 41 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 42 | @Post('/generate-psbt') 43 | async generatePsbt( 44 | @Request() req, 45 | @Body() body: GenerateSwapPsbtDto, 46 | ): Promise<{ psbt: string }> { 47 | const { psbt } = await this.swapOfferService.generatePsbt({ 48 | address: req.user.address, 49 | buyerInscriptionIds: body.buyerInscriptionIds, 50 | sellerInscriptionIds: body.sellerInscriptionIds, 51 | walletType: body.walletType, 52 | price: body.price, 53 | expiredIn: body.expiredIn, 54 | }); 55 | 56 | return { 57 | psbt, 58 | }; 59 | } 60 | 61 | @ApiBearerAuth() 62 | @UseGuards(JwtAuthGuard) 63 | @ApiOperation({ description: `Cancel swap offer`, tags: ['Swap offer'] }) 64 | @ApiResponse(ApiResponseHelper.success(SignPsbtResult, HttpStatus.CREATED)) 65 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 66 | @Post('/cancel') 67 | async cancelBuyNowOffer( 68 | @Request() req, 69 | @Body() body: CancelSwapOfferDto, 70 | ): Promise<{ msg: string }> { 71 | await this.swapOfferService.cancelSwapOffer(body.uuid, req.user.address); 72 | 73 | return { 74 | msg: 'Successfully cancelled the swap offer', 75 | }; 76 | } 77 | 78 | @ApiBearerAuth() 79 | @UseGuards(JwtAuthGuard) 80 | @ApiOperation({ description: `Buyer sign psbt`, tags: ['Swap offer'] }) 81 | @ApiResponse(ApiResponseHelper.success(SignPsbtResult, HttpStatus.CREATED)) 82 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 83 | @Post('/buyer-sign-psbt') 84 | async buyerSignPsbt( 85 | @Request() req, 86 | @Body() body: BuyerSignPsbtDto, 87 | ): Promise<{ msg: string; offerId: string }> { 88 | const offerUuid = await this.swapOfferService.buyerSignPsbt( 89 | body, 90 | req.user.address, 91 | ); 92 | 93 | return { 94 | msg: 'Successfully created the swap offer', 95 | offerId: offerUuid, 96 | }; 97 | } 98 | 99 | @ApiBearerAuth() 100 | @UseGuards(JwtAuthGuard) 101 | @ApiOperation({ description: `Owner sign psbt`, tags: ['Swap offer'] }) 102 | @ApiResponse(ApiResponseHelper.success(SignPsbtResult, HttpStatus.CREATED)) 103 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 104 | @Post('/seller-sign-psbt') 105 | async ownerSignPsbt( 106 | @Request() req, 107 | @Body() body: SellerSignPsbtDto, 108 | ): Promise { 109 | const txId = await this.swapOfferService.sellerSignPsbt( 110 | body, 111 | req.user.address, 112 | ); 113 | 114 | return { 115 | msg: 'Successfully accepted the swap offer', 116 | txId, 117 | }; 118 | } 119 | 120 | @ApiBearerAuth() 121 | @UseGuards(JwtAuthGuard) 122 | @ApiOperation({ description: `Get Sending offers`, tags: ['Swap offer'] }) 123 | @ApiResponse(ApiResponseHelper.success(PageDto, HttpStatus.OK)) 124 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 125 | @Get('/user-sending-offers') 126 | async getUserSendingOffers( 127 | @Request() req, 128 | @Query() getOfferDto: GetOfferDto, 129 | ) { 130 | return this.swapOfferService.getUserSendingOffers( 131 | req.user.address, 132 | getOfferDto, 133 | ); 134 | } 135 | 136 | @ApiBearerAuth() 137 | @UseGuards(JwtAuthGuard) 138 | @ApiOperation({ description: `Get Getting offers`, tags: ['Swap offer'] }) 139 | @ApiResponse(ApiResponseHelper.success(PageDto, HttpStatus.OK)) 140 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 141 | @Get('/user-getting-offers') 142 | async getUserGettingOffers( 143 | @Request() req, 144 | @Query() getOfferDto: GetOfferDto, 145 | ) { 146 | return this.swapOfferService.getUserGettingOffers( 147 | req.user.address, 148 | getOfferDto, 149 | ); 150 | } 151 | 152 | @ApiOperation({ description: `Get Sending offers`, tags: ['Swap offer'] }) 153 | @ApiResponse(ApiResponseHelper.success(PageDto, HttpStatus.OK)) 154 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 155 | @Get('/sending-offers') 156 | async getSendingOffers(@Query() getOfferDto: GetOfferDto) { 157 | return this.swapOfferService.getSendingOffers(getOfferDto); 158 | } 159 | 160 | @ApiBearerAuth() 161 | @UseGuards(JwtAuthGuard) 162 | @ApiOperation({ description: `Get user history`, tags: ['Swap offer'] }) 163 | @ApiResponse(ApiResponseHelper.success(PageDto, HttpStatus.OK)) 164 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 165 | @Get('/user-history') 166 | async getUserPushedOffers( 167 | @Request() req, 168 | @Query() getOfferDto: GetUserHistoryDto, 169 | ) { 170 | return this.swapOfferService.getUserPushedOffers( 171 | req.user.address, 172 | getOfferDto, 173 | ); 174 | } 175 | 176 | @ApiOperation({ description: `Get history`, tags: ['Swap offer'] }) 177 | @ApiResponse(ApiResponseHelper.success(PageDto, HttpStatus.OK)) 178 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 179 | @Get('/history') 180 | async getPushedOffers(@Query() getOfferDto: GetOfferDto) { 181 | return this.swapOfferService.getPushedOffers(getOfferDto); 182 | } 183 | 184 | @ApiOperation({ 185 | description: `Get supported collect deals`, 186 | tags: ['Swap offer'], 187 | }) 188 | @ApiResponse(ApiResponseHelper.success(PageDto, HttpStatus.OK)) 189 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 190 | @Get('/supported-collection') 191 | async getSupportedCollection() { 192 | return this.swapOfferService.getPushedOffersForSupportCollections(); 193 | } 194 | 195 | @ApiOperation({ description: `Get swap offer details`, tags: ['Swap offer'] }) 196 | @ApiResponse(ApiResponseHelper.success(SwapOffer, HttpStatus.OK)) 197 | @ApiResponse(ApiResponseHelper.validationError(`Validation failed`)) 198 | @Get('/uuid/:uuid') 199 | async getSwapofferById(@Param('uuid') uuid: string) { 200 | return this.swapOfferService.getSwapOfferById(uuid); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/inscription/inscription.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, Logger } from '@nestjs/common'; 2 | import { Network } from 'bitcoinjs-lib'; 3 | import { In, Not } from 'typeorm'; 4 | import axios from 'axios'; 5 | import { bitcoin, testnet } from 'bitcoinjs-lib/src/networks'; 6 | 7 | import { 8 | PageDto, 9 | PageMetaDto, 10 | PageOptionsDto, 11 | } from '@src/common/pagination/pagination.types'; 12 | import { ConfigService } from '@nestjs/config'; 13 | import { UserService } from '@src/user/user.service'; 14 | import { Inscription } from './inscription.entity'; 15 | import { InscriptionRepository } from './inscription.repository'; 16 | 17 | export interface IInscription { 18 | address: string; 19 | inscriptionId: string; 20 | inscriptionNumber: number; 21 | output: string; 22 | outputValue: number; 23 | contentType: string; 24 | } 25 | 26 | @Injectable() 27 | export class InscriptionService { 28 | private network: Network; 29 | private logger: Logger; 30 | 31 | constructor( 32 | private inscriptionRepository: InscriptionRepository, 33 | private configService: ConfigService, 34 | private userService: UserService, 35 | ) { 36 | this.logger = new Logger(); 37 | const networkType = this.configService.get('psbtConfig.network'); 38 | 39 | if (networkType === 'mainnet') this.network = bitcoin; 40 | else this.network = testnet; 41 | } 42 | 43 | async findInscriptionById(inscriptionId: string): Promise { 44 | return this.inscriptionRepository.findOne({ where: { inscriptionId } }); 45 | } 46 | 47 | async findInscriptionByIds(inscriptionIds: string[]): Promise { 48 | return this.inscriptionRepository.find({ 49 | where: { inscriptionId: In(inscriptionIds) }, 50 | }); 51 | } 52 | 53 | async findInscriptionByIdsWithCollection( 54 | inscriptionIds: string[], 55 | ): Promise { 56 | return this.inscriptionRepository.find({ 57 | where: { inscriptionId: In(inscriptionIds), collectionId: Not(1) }, 58 | relations: { collection: true }, 59 | select: { 60 | collection: { 61 | name: true, 62 | imgUrl: true, 63 | description: true, 64 | }, 65 | inscriptionId: true, 66 | }, 67 | }); 68 | } 69 | 70 | async findInscriptionAndSave( 71 | inscriptionIds: string[], 72 | ): Promise { 73 | return Promise.all( 74 | inscriptionIds.map(async (inscriptionId) => { 75 | const inscription = await this.inscriptionRepository.findOne({ 76 | where: { inscriptionId }, 77 | }); 78 | 79 | if (inscription) return inscription; 80 | 81 | const inscriptoinEntity = await this.inscriptionRepository.create({ 82 | inscriptionId, 83 | collectionId: 1, 84 | }); 85 | 86 | const savedInscriptoin = await this.inscriptionRepository.save( 87 | inscriptoinEntity, 88 | ); 89 | 90 | return savedInscriptoin; 91 | }), 92 | ); 93 | } 94 | 95 | async getInscriptionInfo(inscriptionId: string) { 96 | const inscriptionInfo = await this.inscriptionRepository.findOne({ 97 | select: { 98 | collection: { 99 | name: true, 100 | description: true, 101 | imgUrl: true, 102 | }, 103 | }, 104 | where: { inscriptionId }, 105 | relations: { 106 | collection: true, 107 | }, 108 | }); 109 | 110 | const owner = await this.getInscriptionOwner(inscriptionId, this.network); 111 | const user = await this.userService.findByAddress(owner); 112 | 113 | if (user) { 114 | inscriptionInfo['user'] = { 115 | address: user.address, 116 | }; 117 | } 118 | 119 | return inscriptionInfo; 120 | } 121 | 122 | async getOwnedInscriptions(address: string, pageOptionsDto: PageOptionsDto) { 123 | const inscriptions = await this.getInscriptions(address, this.network); 124 | const inscriptionIds = inscriptions.map( 125 | (inscription) => inscription.inscriptionId, 126 | ); 127 | 128 | const inscriptionsInfo = await this.inscriptionRepository.find({ 129 | select: { 130 | collection: { 131 | name: true, 132 | description: true, 133 | imgUrl: true, 134 | }, 135 | updatedAt: false, 136 | deletedAt: false, 137 | }, 138 | where: { 139 | inscriptionId: In(inscriptionIds), 140 | }, 141 | relations: { 142 | collection: true, 143 | }, 144 | skip: 145 | pageOptionsDto.skip ?? (pageOptionsDto.page - 1) * pageOptionsDto.take, 146 | take: pageOptionsDto.take, 147 | }); 148 | 149 | if (inscriptionIds.length === 0) 150 | return new PageDto([], new PageMetaDto({ itemCount: 0, pageOptionsDto })); 151 | 152 | const itemCount = await this.inscriptionRepository 153 | .createQueryBuilder('inscription') 154 | .where(`inscription_id IN (:...ids)`, { ids: inscriptionIds }) 155 | .getCount(); 156 | 157 | const pageMetaDto = new PageMetaDto({ itemCount, pageOptionsDto }); 158 | 159 | const entities = inscriptionsInfo.map((inscriptionInfo: any) => { 160 | const inscription = inscriptions.find( 161 | (inscription) => 162 | inscription.inscriptionId === inscriptionInfo.inscriptionId, 163 | ); 164 | 165 | inscriptionInfo.contentType = inscription.contentType; 166 | 167 | if (inscriptionInfo.buyNowActivity.length > 0) { 168 | if (inscriptionInfo.buyNowActivity[0].user.address !== address) { 169 | console.log( 170 | 'inscriptionInfo.buyNowActivity[0].address', 171 | inscriptionInfo.buyNowActivity[0].address, 172 | address, 173 | ); 174 | inscriptionInfo.buyNowActivity = []; 175 | } 176 | } 177 | 178 | delete inscriptionInfo.id; 179 | delete inscriptionInfo.uuid; 180 | delete inscriptionInfo.collectionId; 181 | delete inscriptionInfo.createdAt; 182 | delete inscriptionInfo.updatedAt; 183 | delete inscriptionInfo.deletedAt; 184 | 185 | return inscriptionInfo; 186 | }); 187 | 188 | return new PageDto(entities, pageMetaDto); 189 | } 190 | 191 | async createInscription(collectionId: number, inscriptionId: string) { 192 | const inscription = await this.inscriptionRepository.findOne({ 193 | where: { inscriptionId }, 194 | }); 195 | 196 | if (inscription) 197 | return this.inscriptionRepository.update( 198 | { inscriptionId }, 199 | { collectionId }, 200 | ); 201 | 202 | const inscriptionEntity: Partial = { 203 | collectionId, 204 | inscriptionId, 205 | }; 206 | 207 | this.inscriptionRepository.save(inscriptionEntity); 208 | } 209 | 210 | async checkInscriptionOwner( 211 | address: string, 212 | inscriptionId: string, 213 | ): Promise { 214 | const ownerAddress = await this.getInscriptionOwner( 215 | inscriptionId, 216 | this.network, 217 | ); 218 | 219 | return address === ownerAddress; 220 | } 221 | 222 | async getInscriptions( 223 | address: string, 224 | network: Network, 225 | ): Promise { 226 | const inscriptions: IInscription[] = []; 227 | 228 | const headers: HeadersInit = 229 | network === testnet 230 | ? { 'X-Client': 'UniSat Wallet' } 231 | : { Accept: 'application/json' }; 232 | 233 | let cursor = 0; 234 | const pageSize = 20; 235 | 236 | let done = false; 237 | 238 | try { 239 | while (!done) { 240 | const url = `${ 241 | network === testnet 242 | ? `https://unisat.io/testnet/wallet-api-v4/address/inscriptions?address=${address}&cursor=${cursor}&size=${pageSize}` 243 | : `https://api.hiro.so/ordinals/v1/inscriptions?address=${address}&offset=${cursor}&limit=${pageSize}` 244 | }`; 245 | 246 | const res = await axios.get(url, { headers }); 247 | const inscriptionDatas = res.data; 248 | 249 | if (network === testnet) { 250 | inscriptionDatas.result.list.forEach((inscriptionData: any) => { 251 | inscriptions.push({ 252 | address: inscriptionData.address, 253 | inscriptionId: inscriptionData.inscriptionId, 254 | inscriptionNumber: inscriptionData.inscriptionNumber, 255 | output: inscriptionData.output, 256 | outputValue: inscriptionData.outputValue, 257 | contentType: inscriptionData.contentType, 258 | }); 259 | }); 260 | } else { 261 | inscriptionDatas.results.forEach((inscriptionData: any) => { 262 | inscriptions.push({ 263 | address: inscriptionData.address, 264 | inscriptionId: inscriptionData.id, 265 | inscriptionNumber: inscriptionData.number, 266 | output: inscriptionData.output, 267 | outputValue: inscriptionData.value, 268 | contentType: inscriptionData.content_type, 269 | }); 270 | }); 271 | } 272 | 273 | if (network === testnet) { 274 | if (inscriptionDatas.result.list.length < pageSize) { 275 | done = true; 276 | } else { 277 | cursor += pageSize; 278 | } 279 | } else { 280 | if (inscriptionDatas.results.length < pageSize) { 281 | done = true; 282 | } else { 283 | cursor += pageSize; 284 | } 285 | } 286 | } 287 | 288 | return inscriptions; 289 | } catch (error) { 290 | this.logger.error(error); 291 | 292 | throw new BadRequestException( 293 | 'Ordinal api is not working now. Try again later', 294 | ); 295 | } 296 | } 297 | 298 | async getPaginatedInscriptions( 299 | collectionId: number, 300 | pageOptionsDto: PageOptionsDto, 301 | ): Promise> { 302 | const queryBuilder = 303 | this.inscriptionRepository.createQueryBuilder('inscription'); 304 | 305 | queryBuilder 306 | .where(`inscription.collection_id=${collectionId}`) 307 | .skip( 308 | pageOptionsDto.skip ?? (pageOptionsDto.page - 1) * pageOptionsDto.take, 309 | ) 310 | .take(pageOptionsDto.take); 311 | 312 | const itemCount = await queryBuilder.getCount(); 313 | const { entities } = await queryBuilder.getRawAndEntities(); 314 | 315 | const inscriptionIds = entities.map( 316 | (inscription) => inscription.inscriptionId, 317 | ); 318 | 319 | const pageMetaDto = new PageMetaDto({ itemCount, pageOptionsDto }); 320 | 321 | return new PageDto(inscriptionIds, pageMetaDto); 322 | } 323 | 324 | async getInscriptionOwner( 325 | inscriptionId: string, 326 | network: Network, 327 | ): Promise { 328 | try { 329 | if (network === testnet) { 330 | const url = `https://unisat.io/testnet/wallet-api-v4/inscription/utxo-detail`; 331 | 332 | const headers = { 333 | 'X-Client': 'UniSat Wallet', 334 | }; 335 | 336 | const result = await axios.get(url, { 337 | headers, 338 | params: { 339 | inscriptionId, 340 | }, 341 | }); 342 | 343 | return result.data.result.inscriptions[0].address; 344 | } else { 345 | const url = 'https://api.hiro.so/ordinals/v1/inscriptions'; 346 | 347 | const result = await axios.get(url, { 348 | params: { 349 | id: inscriptionId, 350 | }, 351 | }); 352 | 353 | return result.data.results[0].address; 354 | } 355 | } catch (error) { 356 | this.logger.error(error); 357 | 358 | throw new BadRequestException( 359 | 'Ordinal api is not working now. Try again later', 360 | ); 361 | } 362 | } 363 | 364 | async search(keyWord: string): Promise { 365 | const inscription = await this.inscriptionRepository.findOne({ 366 | where: { 367 | inscriptionId: keyWord, 368 | }, 369 | relations: { 370 | collection: true, 371 | }, 372 | select: { 373 | collection: { 374 | name: true, 375 | imgUrl: true, 376 | }, 377 | }, 378 | order: { 379 | updatedAt: 'DESC', 380 | }, 381 | }); 382 | 383 | if (!inscription) return; 384 | 385 | delete inscription.id; 386 | delete inscription.uuid; 387 | delete inscription.createdAt; 388 | delete inscription.updatedAt; 389 | delete inscription.deletedAt; 390 | delete inscription.collectionId; 391 | 392 | return inscription; 393 | } 394 | } 395 | -------------------------------------------------------------------------------- /src/psbt/psbt.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, Logger } from '@nestjs/common'; 2 | import axios, { AxiosError } from 'axios'; 3 | import { type Network } from 'bitcoinjs-lib'; 4 | import * as Bitcoin from 'bitcoinjs-lib'; 5 | import * as ecc from 'tiny-secp256k1'; 6 | import { ConfigService } from '@nestjs/config'; 7 | import { testnet, bitcoin } from 'bitcoinjs-lib/src/networks'; 8 | 9 | import { WalletTypes } from '@src/user/user.entity'; 10 | 11 | Bitcoin.initEccLib(ecc); 12 | 13 | export const SIGNATURE_SIZE = 126; 14 | 15 | export interface IInscriptionWithUtxo extends IUtxo, IInscription {} 16 | 17 | export interface IInscription { 18 | address: string; 19 | inscriptionId: string; 20 | inscriptionNumber: number; 21 | contentType: string; 22 | } 23 | 24 | interface IUtxo { 25 | txid: string; 26 | vout: number; 27 | value: number; 28 | scriptpubkey?: string; 29 | } 30 | 31 | interface BatchInscriptionInfo { 32 | [index: string]: Partial; 33 | } 34 | 35 | @Injectable() 36 | export class PsbtService { 37 | private feePercent: number; 38 | private adminAddress: string; 39 | private network: Network; 40 | private readonly logger: Logger; 41 | private unisatApiKey: string; 42 | private bisApiKey: string; 43 | 44 | constructor(private configService: ConfigService) { 45 | this.logger = new Logger(PsbtService.name); 46 | 47 | this.feePercent = this.configService.get('psbtConfig.feePercent'); 48 | this.adminAddress = this.configService.get('psbtConfig.adminAddress'); 49 | const networkType = this.configService.get('psbtConfig.network'); 50 | this.unisatApiKey = this.configService.get('psbtConfig.unisatApiKey'); 51 | this.bisApiKey = this.configService.get('psbtConfig.bisApiKey'); 52 | 53 | if (networkType === 'mainnet') this.network = bitcoin; 54 | else this.network = testnet; 55 | } 56 | 57 | async generateSwapPsbt({ 58 | walletType, 59 | sellerInscriptionIds, 60 | buyerInscriptionIds, 61 | price, 62 | }: { 63 | walletType: WalletTypes; 64 | sellerInscriptionIds: string[]; 65 | buyerInscriptionIds: string[]; 66 | price: number; 67 | }): Promise<{ psbt: string; buyerAddress: string; sellerAddress: string }> { 68 | const buyerInscriptionsWithUtxo = await Promise.all( 69 | buyerInscriptionIds.map((inscriptionId) => 70 | this.getInscriptionWithUtxo(inscriptionId), 71 | ), 72 | ); 73 | const sellerInscriptionsWithUtxo = await Promise.all( 74 | sellerInscriptionIds.map((inscriptionId) => 75 | this.getInscriptionWithUtxo(inscriptionId), 76 | ), 77 | ); 78 | 79 | const buyerAddress = buyerInscriptionsWithUtxo[0].address; 80 | const sellerAddress = sellerInscriptionsWithUtxo[0].address; 81 | 82 | const buyerScriptpubkey = Buffer.from( 83 | buyerInscriptionsWithUtxo[0].scriptpubkey, 84 | 'hex', 85 | ); 86 | const sellerScriptpubkey = Buffer.from( 87 | sellerInscriptionsWithUtxo[0].scriptpubkey, 88 | 'hex', 89 | ); 90 | 91 | const psbt = new Bitcoin.Psbt({ network: this.network }); 92 | 93 | buyerInscriptionsWithUtxo.forEach((inscriptionUtxo) => { 94 | psbt.addInput({ 95 | hash: inscriptionUtxo.txid, 96 | index: inscriptionUtxo.vout, 97 | witnessUtxo: { 98 | value: inscriptionUtxo.value, 99 | script: buyerScriptpubkey, 100 | }, 101 | sighashType: Bitcoin.Transaction.SIGHASH_ALL, 102 | }); 103 | 104 | psbt.addOutput({ 105 | address: sellerAddress, 106 | value: inscriptionUtxo.value, 107 | }); 108 | }); 109 | 110 | sellerInscriptionsWithUtxo.forEach((inscriptionUtxo) => { 111 | psbt.addInput({ 112 | hash: inscriptionUtxo.txid, 113 | index: inscriptionUtxo.vout, 114 | witnessUtxo: { 115 | value: inscriptionUtxo.value, 116 | script: sellerScriptpubkey, 117 | }, 118 | sighashType: Bitcoin.Transaction.SIGHASH_ALL, 119 | }); 120 | 121 | psbt.addOutput({ 122 | address: buyerAddress, 123 | value: inscriptionUtxo.value, 124 | }); 125 | }); 126 | 127 | const btcUtxos = await this.getBtcUtxoByAddress(buyerAddress); 128 | const feeRate = await this.getFeeRate(this.network); 129 | 130 | let amount = 0; 131 | 132 | for (const utxo of btcUtxos) { 133 | const fee = this.calculateTxFee(psbt, feeRate); 134 | 135 | if (amount < price + fee && utxo.value > 10000) { 136 | amount += utxo.value; 137 | psbt.addInput({ 138 | hash: utxo.txid, 139 | index: utxo.vout, 140 | witnessUtxo: { 141 | value: utxo.value, 142 | script: buyerScriptpubkey, 143 | }, 144 | sighashType: Bitcoin.Transaction.SIGHASH_ALL, 145 | }); 146 | } 147 | } 148 | 149 | const fee = this.calculateTxFee(psbt, feeRate); 150 | 151 | if (amount < price + fee) 152 | throw new BadRequestException( 153 | "You don't have enough bitcoin in your wallet.", 154 | ); 155 | 156 | if (price > 0) 157 | psbt.addOutput({ 158 | address: sellerAddress, 159 | value: price, 160 | }); 161 | 162 | psbt.addOutput({ 163 | address: buyerAddress, 164 | value: amount - price - fee, 165 | }); 166 | 167 | return { psbt: psbt.toHex(), buyerAddress, sellerAddress }; 168 | } 169 | 170 | calculateTxFee(psbt: Bitcoin.Psbt, feeRate: number): number { 171 | const tx = new Bitcoin.Transaction(); 172 | 173 | for (let i = 0; i < psbt.txInputs.length; i++) { 174 | const txInput = psbt.txInputs[i]; 175 | 176 | tx.addInput(txInput.hash, txInput.index, txInput.sequence); 177 | tx.setWitness(i, [Buffer.alloc(SIGNATURE_SIZE)]); 178 | } 179 | 180 | for (let txOutput of psbt.txOutputs) { 181 | tx.addOutput(txOutput.script, txOutput.value); 182 | } 183 | tx.addOutput(psbt.txOutputs[0].script, psbt.txOutputs[0].value); 184 | tx.addOutput(psbt.txOutputs[0].script, psbt.txOutputs[0].value); 185 | 186 | return tx.virtualSize() * feeRate; 187 | } 188 | 189 | async getUtxos(address: string, network: Network): Promise { 190 | try { 191 | const url = `https://mempool.space/${ 192 | network === testnet ? 'testnet/' : '' 193 | }api/address/${address}/utxo`; 194 | const res = await axios.get(url); 195 | const utxos: IUtxo[] = []; 196 | res.data.forEach((utxoData: any) => { 197 | utxos.push({ 198 | txid: utxoData.txid, 199 | vout: utxoData.vout, 200 | value: utxoData.value, 201 | }); 202 | }); 203 | 204 | return utxos; 205 | } catch (error) { 206 | throw new BadRequestException( 207 | 'Ordinal api is not working now. Try again later', 208 | ); 209 | } 210 | } 211 | 212 | async getFeeRate(network: Network): Promise { 213 | try { 214 | const url = `https://mempool.space/${ 215 | network === testnet ? 'testnet/' : '' 216 | }api/v1/fees/recommended`; 217 | 218 | const res = await axios.get(url); 219 | 220 | return res.data.halfHourFee; 221 | } catch (error) { 222 | throw new BadRequestException( 223 | 'Ordinal api is not working now. Try again later', 224 | ); 225 | } 226 | } 227 | 228 | convertHexedToBase64(hexedPsbt: string): string { 229 | const psbt = Bitcoin.Psbt.fromHex(hexedPsbt); 230 | return psbt.toBase64(); 231 | } 232 | 233 | convertBase64ToHexed(base64Psbt: string): string { 234 | const psbt = Bitcoin.Psbt.fromBase64(base64Psbt); 235 | return psbt.toHex(); 236 | } 237 | 238 | async pushRawTx(rawTx: string, network: Network): Promise { 239 | this.logger.log('rawTx', rawTx); 240 | const txid = await this.postData( 241 | `https://mempool.space/${network === testnet ? 'testnet/' : ''}api/tx`, 242 | rawTx, 243 | ); 244 | this.logger.log('pushed txid', txid); 245 | return txid; 246 | } 247 | 248 | async postData( 249 | url: string, 250 | json: any, 251 | content_type = 'text/plain', 252 | apikey = '', 253 | ): Promise { 254 | while (1) { 255 | try { 256 | const headers: any = {}; 257 | 258 | if (content_type) headers['Content-Type'] = content_type; 259 | 260 | if (apikey) headers['X-Api-Key'] = apikey; 261 | const res = await axios.post(url, json, { 262 | headers, 263 | }); 264 | 265 | return res.data as string; 266 | } catch (err) { 267 | const axiosErr = err as AxiosError; 268 | this.logger.error('push tx error', axiosErr.response?.data); 269 | 270 | if ( 271 | !(axiosErr.response?.data as string).includes( 272 | 'sendrawtransaction RPC error: {"code":-26,"message":"too-long-mempool-chain,', 273 | ) 274 | ) 275 | throw new Error('Got an err when push tx'); 276 | } 277 | } 278 | } 279 | 280 | async combinePsbt( 281 | hexedPsbt: string, 282 | signedHexedPsbt1: string, 283 | signedHexedPsbt2: string, 284 | ): Promise { 285 | const psbt = Bitcoin.Psbt.fromHex(hexedPsbt); 286 | const signedPsbt1 = Bitcoin.Psbt.fromHex(signedHexedPsbt1); 287 | const signedPsbt2 = Bitcoin.Psbt.fromHex(signedHexedPsbt2); 288 | 289 | psbt.combine(signedPsbt1, signedPsbt2); 290 | 291 | return psbt.toHex(); 292 | } 293 | 294 | async combinePsbtAndPush( 295 | hexedPsbt: string, 296 | signedHexedPsbt1: string, 297 | signedHexedPsbt2: string, 298 | ): Promise { 299 | const psbt = Bitcoin.Psbt.fromHex(hexedPsbt); 300 | const signedPsbt1 = Bitcoin.Psbt.fromHex(signedHexedPsbt1); 301 | const signedPsbt2 = Bitcoin.Psbt.fromHex(signedHexedPsbt2); 302 | 303 | psbt.combine(signedPsbt1, signedPsbt2); 304 | const tx = psbt.extractTransaction(); 305 | const txHex = tx.toHex(); 306 | 307 | const txId = await this.pushRawTx(txHex, this.network); 308 | return txId; 309 | } 310 | 311 | finalizePsbtInput(hexedPsbt: string, inputs: number[]): string { 312 | const psbt = Bitcoin.Psbt.fromHex(hexedPsbt); 313 | inputs.forEach((input) => psbt.finalizeInput(input)); 314 | return psbt.toHex(); 315 | } 316 | 317 | getInputCount(hexedPsbt: string): number { 318 | const psbt = Bitcoin.Psbt.fromHex(hexedPsbt); 319 | return psbt.inputCount; 320 | } 321 | 322 | async getInscriptionWithUtxo( 323 | inscriptionId: string, 324 | ): Promise { 325 | try { 326 | const url = 327 | this.network === testnet 328 | ? `https://open-api-testnet.unisat.io/v1/indexer/inscription/info/${inscriptionId}` 329 | : `https://open-api.unisat.io/v1/indexer/inscription/info/${inscriptionId}`; 330 | 331 | const config = { 332 | headers: { 333 | Authorization: `Bearer ${this.unisatApiKey}`, 334 | }, 335 | }; 336 | 337 | const res = await axios.get(url, config); 338 | 339 | if (res.data.code === -1) 340 | throw new BadRequestException('Invalid inscription id'); 341 | 342 | return { 343 | address: res.data.data.address, 344 | contentType: res.data.data.contentType, 345 | inscriptionId: inscriptionId, 346 | inscriptionNumber: res.data.data.inscriptionNumber, 347 | txid: res.data.data.utxo.txid, 348 | value: res.data.data.utxo.satoshi, 349 | vout: res.data.data.utxo.vout, 350 | scriptpubkey: res.data.data.utxo.scriptPk, 351 | }; 352 | } catch (error) { 353 | this.logger.error( 354 | `Ordinal api is not working now, please try again later Or invalid inscription id ${inscriptionId}`, 355 | ); 356 | throw new BadRequestException( 357 | `Ordinal api is not working now, please try again later Or invalid inscription id ${inscriptionId}`, 358 | ); 359 | } 360 | } 361 | 362 | async getScriptpubkey(address: string, txid: string): Promise { 363 | try { 364 | const url = 365 | this.network === testnet 366 | ? `https://mempool.space/testnet/api/tx/${txid}` 367 | : `https://mempool.space/api/tx/${txid}`; 368 | 369 | const res = await axios.get(url); 370 | 371 | const vout = res.data.vout as any[]; 372 | const foundOut = vout.find((out) => out.scriptpubkey_address === address); 373 | 374 | return foundOut.scriptpubkey; 375 | } catch (error) { 376 | this.logger.error( 377 | 'Mempool api is not working now, Please try again later', 378 | ); 379 | throw new BadRequestException( 380 | 'Mempool api is not working now, Please try again later', 381 | ); 382 | } 383 | } 384 | 385 | async getBtcUtxoByAddress(address): Promise { 386 | const url = 387 | this.network === testnet 388 | ? `https://open-api-testnet.unisat.io/v1/indexer/address/${address}/utxo-data` 389 | : `https://open-api.unisat.io/v1/indexer/address/${address}/utxo-data`; 390 | 391 | const config = { 392 | headers: { 393 | Authorization: `Bearer ${this.unisatApiKey}`, 394 | }, 395 | }; 396 | 397 | let cursor = 0; 398 | const size = 200; 399 | const utxos: IUtxo[] = []; 400 | 401 | while (1) { 402 | const res = await axios.get(url, { ...config, params: { cursor, size } }); 403 | 404 | if (res.data.code === -1) throw new BadRequestException('Invalid addres'); 405 | 406 | utxos.push( 407 | ...(res.data.data.utxo as any[]).map((utxo) => { 408 | return { 409 | scriptpubkey: utxo.scriptPk, 410 | txid: utxo.txid, 411 | value: utxo.satoshi, 412 | vout: utxo.vout, 413 | }; 414 | }), 415 | ); 416 | 417 | cursor += res.data.data.utxo.length; 418 | 419 | if (cursor === res.data.data.total) break; 420 | } 421 | 422 | return utxos; 423 | } 424 | 425 | async getInscriptionUtxoByAddress(address: string): Promise { 426 | try { 427 | const url = 428 | this.network === testnet 429 | ? `https://open-api-testnet.unisat.io/v1/indexer/address/${address}/inscription-utxo-data` 430 | : `https://open-api.unisat.io/v1/indexer/address/${address}/inscription-utxo-data`; 431 | 432 | const config = { 433 | headers: { 434 | Authorization: `Bearer ${this.unisatApiKey}`, 435 | }, 436 | }; 437 | 438 | let cursor = 0; 439 | const size = 200; 440 | const inscriptionUtxos: IInscription[] = []; 441 | 442 | while (1) { 443 | const res = await axios.get(url, { 444 | ...config, 445 | params: { 446 | cursor, 447 | size, 448 | }, 449 | }); 450 | 451 | if (res.data.code === -1) 452 | throw new BadRequestException('Invalid addres'); 453 | 454 | inscriptionUtxos.push( 455 | ...res.data.data.utxo.map((inscription) => { 456 | return { 457 | address: inscription.address, 458 | inscriptionId: inscription.inscriptions[0].inscriptionId, 459 | inscriptionNumber: inscription.inscriptions[0].inscriptionNumber, 460 | contentType: '', 461 | }; 462 | }), 463 | ); 464 | 465 | cursor += res.data.data.utxo.length; 466 | 467 | if (cursor === res.data.data.total) break; 468 | } 469 | 470 | return inscriptionUtxos; 471 | } catch (error) { 472 | throw new BadRequestException( 473 | 'Ordinal api is not working now or Invalid address', 474 | ); 475 | } 476 | } 477 | 478 | async getInscriptionByAddress(address: string): Promise { 479 | try { 480 | const url = 481 | this.network === testnet 482 | ? `https://open-api-testnet.unisat.io/v1/indexer/address/${address}/inscription-data` 483 | : `https://open-api.unisat.io/v1/indexer/address/${address}/inscription-data`; 484 | 485 | const config = { 486 | headers: { 487 | Authorization: `Bearer ${this.unisatApiKey}`, 488 | }, 489 | }; 490 | 491 | let cursor = 0; 492 | const size = 200; 493 | const inscriptionUtxos: IInscription[] = []; 494 | 495 | while (1) { 496 | const res = await axios.get(url, { 497 | ...config, 498 | params: { 499 | cursor, 500 | size, 501 | }, 502 | }); 503 | 504 | if (res.data.code === -1) 505 | throw new BadRequestException('Invalid address'); 506 | 507 | inscriptionUtxos.push( 508 | ...res.data.data.inscription.map((inscription) => { 509 | return { 510 | address: inscription.address, 511 | inscriptionId: inscription.inscriptionId, 512 | inscriptionNumber: inscription.inscriptionNumber, 513 | contentType: inscription.contentType, 514 | }; 515 | }), 516 | ); 517 | 518 | cursor += res.data.data.inscription.length; 519 | 520 | if (cursor === res.data.data.total) break; 521 | } 522 | 523 | return inscriptionUtxos; 524 | } catch (error) { 525 | throw new BadRequestException( 526 | 'Ordinal api is not working now or Invalid address', 527 | ); 528 | } 529 | } 530 | 531 | async getBatchInscriptionInfoBIS( 532 | inscriptions: string[], 533 | ): Promise { 534 | try { 535 | if (inscriptions.length === 0) return {}; 536 | 537 | const url = 538 | this.network === testnet 539 | ? `https://testnet.api.bestinslot.xyz/v3/inscription/batch_info` 540 | : `https://api.bestinslot.xyz/v3/inscription/batch_info`; 541 | 542 | const res = await axios.post( 543 | url, 544 | { 545 | queries: inscriptions, 546 | }, 547 | { 548 | headers: { 549 | 'x-api-key': this.bisApiKey, 550 | }, 551 | }, 552 | ); 553 | 554 | const batchInscriptionInfo: BatchInscriptionInfo = {}; 555 | 556 | res.data.data.forEach((inscriptionInfo: any) => { 557 | batchInscriptionInfo[inscriptionInfo.query as string] = { 558 | contentType: inscriptionInfo.result.mime_type, 559 | address: inscriptionInfo.result.wallet, 560 | inscriptionId: inscriptionInfo.result.inscription_id, 561 | inscriptionNumber: inscriptionInfo.result.inscription_number, 562 | }; 563 | }); 564 | 565 | return batchInscriptionInfo; 566 | } catch (error) { 567 | throw new BadRequestException( 568 | 'Ordinal api is not working now or Invalid address', 569 | ); 570 | } 571 | } 572 | } 573 | -------------------------------------------------------------------------------- /src/swap-offer/swap-offer.service.ts: -------------------------------------------------------------------------------- 1 | import { Cron, CronExpression } from '@nestjs/schedule'; 2 | import { And, Brackets, In, LessThan, Not } from 'typeorm'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { BadRequestException, Injectable } from '@nestjs/common'; 5 | import { testnet, bitcoin, Network } from 'bitcoinjs-lib/src/networks'; 6 | 7 | import { WalletTypes } from '@src/user/user.entity'; 8 | import { InscriptionService } from '@src/inscription/inscription.service'; 9 | import { PsbtService } from '@src/psbt/psbt.service'; 10 | import { UserService } from '@src/user/user.service'; 11 | import { BuyerSignPsbtDto } from './dto/buyer-sign-psbt.dto'; 12 | import { SellerSignPsbtDto } from './dto/seller-sign-psbt.dto'; 13 | import { PageDto, PageMetaDto } from '@src/common/pagination/pagination.types'; 14 | import { SwapOfferRepository } from './swap-offer.repository'; 15 | import { OfferStatus, SwapOffer } from './swap-offer.entity'; 16 | import { BuyerSwapInscriptionRepository } from './buyer-swap-inscription.repository'; 17 | import { SellerSwapInscriptionRepository } from './seller-swap-inscription.repository'; 18 | import { Inscription } from '@src/inscription/inscription.entity'; 19 | import { BuyerSwapInscription } from './buyer-swap-inscription.entity'; 20 | import { SellerSwapInscription } from './seller-swap-inscription.entity'; 21 | import axios from 'axios'; 22 | import { GetOfferDto } from './dto/get-offer.dto'; 23 | import { GetUserHistoryDto } from './dto/get-user-history.dto'; 24 | 25 | @Injectable() 26 | export class SwapOfferService { 27 | private network: Network; 28 | 29 | constructor( 30 | private swapOfferRepository: SwapOfferRepository, 31 | private buyerSwapInscriptionRepository: BuyerSwapInscriptionRepository, 32 | private sellerSwapInscriptionRepository: SellerSwapInscriptionRepository, 33 | private psbtService: PsbtService, 34 | private userService: UserService, 35 | private inscriptionService: InscriptionService, 36 | private configService: ConfigService, 37 | ) { 38 | const networkType = this.configService.get('psbtConfig.network'); 39 | 40 | if (networkType === 'mainnet') this.network = bitcoin; 41 | else this.network = testnet; 42 | } 43 | 44 | async generatePsbt({ 45 | address, 46 | buyerInscriptionIds, 47 | sellerInscriptionIds, 48 | walletType, 49 | price = 0, 50 | expiredIn, 51 | }: { 52 | address: string; 53 | buyerInscriptionIds: string[]; 54 | sellerInscriptionIds: string[]; 55 | walletType: WalletTypes; 56 | price?: number; 57 | expiredIn: string; 58 | }) { 59 | const { psbt, buyerAddress, sellerAddress } = 60 | await this.psbtService.generateSwapPsbt({ 61 | walletType, 62 | sellerInscriptionIds, 63 | buyerInscriptionIds, 64 | price: Math.floor(price * 10 ** 8), 65 | }); 66 | 67 | if (address !== buyerAddress) 68 | throw new BadRequestException( 69 | 'You are not the owner of this inscription', 70 | ); 71 | 72 | let [buyer, seller] = await Promise.all([ 73 | this.userService.findByAddress(buyerAddress), 74 | this.userService.findByAddress(sellerAddress), 75 | ]); 76 | 77 | if (!buyer) buyer = await this.userService.createWithAddress(buyerAddress); 78 | if (!seller) 79 | seller = await this.userService.createWithAddress(sellerAddress); 80 | 81 | const [buyerInscriptions, sellerInscriptions] = await Promise.all([ 82 | this.inscriptionService.findInscriptionAndSave(buyerInscriptionIds), 83 | this.inscriptionService.findInscriptionAndSave(sellerInscriptionIds), 84 | ]); 85 | 86 | const expiredAt = new Date(); 87 | const time = expiredIn.match(/\d+/)[0]; 88 | 89 | if (expiredIn.endsWith('m')) { 90 | const minutes = expiredAt.getMinutes(); 91 | expiredAt.setMinutes(minutes + Number(time)); 92 | } else if (expiredIn.endsWith('h')) { 93 | const hours = expiredAt.getHours(); 94 | expiredAt.setHours(hours + Number(time)); 95 | } else if (expiredIn.endsWith('d')) { 96 | const date = expiredAt.getDate(); 97 | expiredAt.setDate(date + Number(time)); 98 | } 99 | 100 | const swapOffer = this.swapOfferRepository.create({ 101 | price, 102 | status: OfferStatus.CREATED, 103 | psbt, 104 | buyer, 105 | seller, 106 | expiredAt, 107 | }); 108 | 109 | const savedSwapOffer = await this.swapOfferRepository.save(swapOffer); 110 | 111 | await Promise.all( 112 | buyerInscriptions.map((inscription) => 113 | this.saveBuyerSwapInscription(inscription, savedSwapOffer), 114 | ), 115 | ); 116 | await Promise.all( 117 | sellerInscriptions.map((inscription) => 118 | this.saveSellerSwapInscription(inscription, savedSwapOffer), 119 | ), 120 | ); 121 | 122 | return { psbt }; 123 | } 124 | 125 | async saveBuyerSwapInscription( 126 | inscription: Inscription, 127 | swapOffer: SwapOffer, 128 | ): Promise> { 129 | const swapInscriptionEntity: Partial = { 130 | inscription, 131 | swapOffer, 132 | }; 133 | const swapInscription = await this.buyerSwapInscriptionRepository.save( 134 | swapInscriptionEntity, 135 | { reload: false }, 136 | ); 137 | 138 | return swapInscription; 139 | } 140 | 141 | async saveSellerSwapInscription( 142 | inscription: Inscription, 143 | swapOffer: SwapOffer, 144 | ): Promise> { 145 | const swapInscriptionEntity: Partial = { 146 | inscription, 147 | swapOffer, 148 | }; 149 | const swapInscription = await this.sellerSwapInscriptionRepository.save( 150 | swapInscriptionEntity, 151 | { reload: false }, 152 | ); 153 | 154 | return swapInscription; 155 | } 156 | 157 | async cancelSwapOffer(uuid: string, address: string): Promise { 158 | const user = await this.userService.findByAddress(address); 159 | 160 | const swapOffer = await this.swapOfferRepository.findOne({ 161 | where: { 162 | uuid, 163 | }, 164 | relations: { 165 | buyer: true, 166 | seller: true, 167 | }, 168 | }); 169 | 170 | if (!swapOffer) 171 | throw new BadRequestException('Can not find the buy now offer'); 172 | 173 | if (swapOffer.buyer.id !== user.id && swapOffer.seller.id !== user.id) 174 | throw new BadRequestException('You can not cancel the offer'); 175 | 176 | await this.swapOfferRepository.update( 177 | { 178 | uuid, 179 | }, 180 | { status: OfferStatus.CANCELED }, 181 | ); 182 | 183 | return true; 184 | } 185 | 186 | async buyerSignPsbt( 187 | body: BuyerSignPsbtDto, 188 | userAddress: string, 189 | ): Promise { 190 | const user = await this.userService.findByAddress(userAddress); 191 | 192 | const swapOffer = await this.swapOfferRepository.findOne({ 193 | where: { psbt: body.psbt, buyer: { id: user.id } }, 194 | }); 195 | 196 | if (!swapOffer) 197 | throw new BadRequestException('Can not find that swap offer'); 198 | 199 | const signedPsbt = body.signedPsbt; 200 | 201 | await this.swapOfferRepository.update( 202 | { psbt: body.psbt }, 203 | { 204 | buyerSignedPsbt: signedPsbt, 205 | status: OfferStatus.SIGNED, 206 | }, 207 | ); 208 | 209 | return swapOffer.uuid; 210 | } 211 | 212 | async sellerSignPsbt( 213 | body: SellerSignPsbtDto, 214 | userAddress: string, 215 | ): Promise { 216 | const seller = await this.userService.findByAddress(userAddress); 217 | 218 | const swapOffer = await this.swapOfferRepository.findOne({ 219 | where: { 220 | psbt: body.psbt, 221 | seller: { id: seller.id }, 222 | }, 223 | }); 224 | 225 | if (!swapOffer) 226 | throw new BadRequestException('Can not find that swap now offer'); 227 | 228 | const signedPsbt = body.signedPsbt; 229 | 230 | await this.swapOfferRepository.update( 231 | { psbt: body.psbt }, 232 | { 233 | sellerSignedPsbt: signedPsbt, 234 | status: OfferStatus.ACCEPTED, 235 | }, 236 | ); 237 | 238 | try { 239 | const txId = await this.psbtService.combinePsbtAndPush( 240 | swapOffer.psbt, 241 | swapOffer.buyerSignedPsbt, 242 | signedPsbt, 243 | ); 244 | await this.swapOfferRepository.update( 245 | { 246 | id: swapOffer.id, 247 | }, 248 | { status: OfferStatus.PENDING, txId }, 249 | ); 250 | 251 | return txId; 252 | } catch (error) { 253 | await this.swapOfferRepository.update( 254 | { 255 | id: swapOffer.id, 256 | }, 257 | { status: OfferStatus.FAILED }, 258 | ); 259 | 260 | throw new BadRequestException( 261 | 'Transaction failed to push, buyer should create a psbt again', 262 | ); 263 | } 264 | } 265 | 266 | async getUserSendingOffers(ownerAddress: string, getOfferDto: GetOfferDto) { 267 | const user = await this.userService.findByAddress(ownerAddress); 268 | 269 | const [swapOfferIds, itemCount] = 270 | await this.swapOfferRepository.findAndCount({ 271 | select: { 272 | id: true, 273 | updatedAt: true, 274 | }, 275 | where: [ 276 | { 277 | buyer: { id: user.id }, 278 | status: OfferStatus.SIGNED, 279 | buyerSwapInscription: { 280 | inscription: { inscriptionId: getOfferDto.keyword }, 281 | }, 282 | }, 283 | { 284 | buyer: { id: user.id }, 285 | status: OfferStatus.SIGNED, 286 | sellerSwapInscription: { 287 | inscription: { inscriptionId: getOfferDto.keyword }, 288 | }, 289 | }, 290 | { 291 | buyer: { id: user.id }, 292 | status: OfferStatus.SIGNED, 293 | seller: { address: getOfferDto.keyword }, 294 | }, 295 | ], 296 | relations: { 297 | buyerSwapInscription: { inscription: true }, 298 | sellerSwapInscription: { inscription: true }, 299 | buyer: true, 300 | seller: true, 301 | }, 302 | skip: getOfferDto.skip ?? (getOfferDto.page - 1) * getOfferDto.take, 303 | take: getOfferDto.take, 304 | order: { 305 | updatedAt: 'DESC', 306 | }, 307 | }); 308 | 309 | const swapOffers = await this.swapOfferRepository.find({ 310 | select: { 311 | buyer: { 312 | address: true, 313 | }, 314 | }, 315 | where: { 316 | id: In(swapOfferIds.map((inscription) => inscription.id)), 317 | }, 318 | relations: { 319 | buyerSwapInscription: { inscription: true }, 320 | sellerSwapInscription: { inscription: true }, 321 | buyer: true, 322 | seller: true, 323 | }, 324 | }); 325 | 326 | const inscriptionIds: string[] = []; 327 | 328 | swapOffers.forEach((swapOffer) => { 329 | swapOffer.buyerSwapInscription.forEach((inscription) => 330 | inscriptionIds.push(inscription.inscription.inscriptionId), 331 | ); 332 | swapOffer.sellerSwapInscription.forEach((inscription) => 333 | inscriptionIds.push(inscription.inscription.inscriptionId), 334 | ); 335 | }); 336 | 337 | const batchInscriptionInfo = 338 | await this.psbtService.getBatchInscriptionInfoBIS(inscriptionIds); 339 | 340 | const entities = swapOffers.map((swapOffer) => { 341 | return { 342 | price: swapOffer.price, 343 | psbt: swapOffer.psbt, 344 | txId: swapOffer.txId, 345 | buyer: swapOffer.buyer, 346 | seller: swapOffer.seller, 347 | uuid: swapOffer.uuid, 348 | expiredAt: swapOffer.expiredAt, 349 | status: swapOffer.status, 350 | buyerInscription: swapOffer.buyerSwapInscription.map( 351 | (inscription) => 352 | batchInscriptionInfo[inscription.inscription.inscriptionId], 353 | ), 354 | sellerInscription: swapOffer.sellerSwapInscription.map( 355 | (inscription) => 356 | batchInscriptionInfo[inscription.inscription.inscriptionId], 357 | ), 358 | }; 359 | }); 360 | 361 | if (user.walletType === WalletTypes.XVERSE) 362 | entities.forEach((offer) => { 363 | offer.psbt = this.psbtService.convertHexedToBase64(offer.psbt); 364 | }); 365 | 366 | const pageMetaDto = new PageMetaDto({ 367 | itemCount, 368 | pageOptionsDto: { 369 | skip: getOfferDto.skip, 370 | order: getOfferDto.order, 371 | page: getOfferDto.page, 372 | take: getOfferDto.take, 373 | }, 374 | }); 375 | 376 | return new PageDto(entities, pageMetaDto); 377 | } 378 | 379 | async getUserGettingOffers(ownerAddress: string, getOfferDto: GetOfferDto) { 380 | const user = await this.userService.findByAddress(ownerAddress); 381 | 382 | const [swapOfferIds, itemCount] = 383 | await this.swapOfferRepository.findAndCount({ 384 | select: { 385 | id: true, 386 | updatedAt: true, 387 | }, 388 | where: [ 389 | { 390 | seller: { id: user.id }, 391 | status: OfferStatus.SIGNED, 392 | buyerSwapInscription: { 393 | inscription: { inscriptionId: getOfferDto.keyword }, 394 | }, 395 | }, 396 | { 397 | seller: { id: user.id }, 398 | status: OfferStatus.SIGNED, 399 | sellerSwapInscription: { 400 | inscription: { inscriptionId: getOfferDto.keyword }, 401 | }, 402 | }, 403 | { 404 | seller: { id: user.id }, 405 | status: OfferStatus.SIGNED, 406 | buyer: { address: getOfferDto.keyword }, 407 | }, 408 | ], 409 | relations: { 410 | buyerSwapInscription: { inscription: true }, 411 | sellerSwapInscription: { inscription: true }, 412 | buyer: true, 413 | seller: true, 414 | }, 415 | skip: getOfferDto.skip ?? (getOfferDto.page - 1) * getOfferDto.take, 416 | take: getOfferDto.take, 417 | order: { 418 | updatedAt: 'DESC', 419 | }, 420 | }); 421 | 422 | const swapOffers = await this.swapOfferRepository.find({ 423 | select: { 424 | buyer: { 425 | address: true, 426 | }, 427 | }, 428 | where: { 429 | id: In(swapOfferIds.map((inscription) => inscription.id)), 430 | }, 431 | relations: { 432 | buyerSwapInscription: { inscription: true }, 433 | sellerSwapInscription: { inscription: true }, 434 | buyer: true, 435 | seller: true, 436 | }, 437 | }); 438 | 439 | const inscriptionIds: string[] = []; 440 | 441 | swapOffers.forEach((swapOffer) => { 442 | swapOffer.buyerSwapInscription.forEach((inscription) => 443 | inscriptionIds.push(inscription.inscription.inscriptionId), 444 | ); 445 | swapOffer.sellerSwapInscription.forEach((inscription) => 446 | inscriptionIds.push(inscription.inscription.inscriptionId), 447 | ); 448 | }); 449 | 450 | const batchInscriptionInfo = 451 | await this.psbtService.getBatchInscriptionInfoBIS(inscriptionIds); 452 | 453 | const entities = swapOffers.map((swapOffer) => { 454 | return { 455 | price: swapOffer.price, 456 | psbt: swapOffer.psbt, 457 | txId: swapOffer.txId, 458 | buyer: swapOffer.buyer, 459 | seller: swapOffer.seller, 460 | uuid: swapOffer.uuid, 461 | expiredAt: swapOffer.expiredAt, 462 | status: swapOffer.status, 463 | buyerInscription: swapOffer.buyerSwapInscription.map( 464 | (inscription) => 465 | batchInscriptionInfo[inscription.inscription.inscriptionId], 466 | ), 467 | sellerInscription: swapOffer.sellerSwapInscription.map( 468 | (inscription) => 469 | batchInscriptionInfo[inscription.inscription.inscriptionId], 470 | ), 471 | }; 472 | }); 473 | 474 | if (user.walletType === WalletTypes.XVERSE) 475 | entities.forEach((offer) => { 476 | offer.psbt = this.psbtService.convertHexedToBase64(offer.psbt); 477 | }); 478 | 479 | const pageMetaDto = new PageMetaDto({ 480 | itemCount, 481 | pageOptionsDto: { 482 | skip: getOfferDto.skip, 483 | order: getOfferDto.order, 484 | page: getOfferDto.page, 485 | take: getOfferDto.take, 486 | }, 487 | }); 488 | 489 | return new PageDto(entities, pageMetaDto); 490 | } 491 | 492 | async getSendingOffers(getOfferDto: GetOfferDto) { 493 | const [swapOfferIds, itemCount] = 494 | await this.swapOfferRepository.findAndCount({ 495 | select: { 496 | id: true, 497 | updatedAt: true, 498 | }, 499 | where: [ 500 | { 501 | status: OfferStatus.SIGNED, 502 | buyerSwapInscription: { 503 | inscription: { inscriptionId: getOfferDto.keyword }, 504 | }, 505 | }, 506 | { 507 | status: OfferStatus.SIGNED, 508 | sellerSwapInscription: { 509 | inscription: { inscriptionId: getOfferDto.keyword }, 510 | }, 511 | }, 512 | { 513 | status: OfferStatus.SIGNED, 514 | buyer: { address: getOfferDto.keyword }, 515 | }, 516 | { 517 | status: OfferStatus.SIGNED, 518 | seller: { address: getOfferDto.keyword }, 519 | }, 520 | ], 521 | relations: { 522 | buyerSwapInscription: { inscription: true }, 523 | sellerSwapInscription: { inscription: true }, 524 | buyer: true, 525 | seller: true, 526 | }, 527 | skip: getOfferDto.skip ?? (getOfferDto.page - 1) * getOfferDto.take, 528 | take: getOfferDto.take, 529 | order: { 530 | updatedAt: 'DESC', 531 | }, 532 | }); 533 | 534 | const swapOffers = await this.swapOfferRepository.find({ 535 | select: { 536 | buyer: { 537 | address: true, 538 | }, 539 | }, 540 | where: { 541 | id: In(swapOfferIds.map((inscription) => inscription.id)), 542 | }, 543 | relations: { 544 | buyerSwapInscription: { inscription: true }, 545 | sellerSwapInscription: { inscription: true }, 546 | buyer: true, 547 | seller: true, 548 | }, 549 | skip: getOfferDto.skip ?? (getOfferDto.page - 1) * getOfferDto.take, 550 | take: getOfferDto.take, 551 | order: { 552 | updatedAt: 'DESC', 553 | }, 554 | }); 555 | 556 | const inscriptionIds: string[] = []; 557 | 558 | swapOffers.forEach((swapOffer) => { 559 | swapOffer.buyerSwapInscription.forEach((inscription) => 560 | inscriptionIds.push(inscription.inscription.inscriptionId), 561 | ); 562 | swapOffer.sellerSwapInscription.forEach((inscription) => 563 | inscriptionIds.push(inscription.inscription.inscriptionId), 564 | ); 565 | }); 566 | 567 | const batchInscriptionInfo = 568 | await this.psbtService.getBatchInscriptionInfoBIS(inscriptionIds); 569 | 570 | const entities = swapOffers.map((swapOffer) => { 571 | return { 572 | price: swapOffer.price, 573 | psbt: swapOffer.psbt, 574 | txId: swapOffer.txId, 575 | buyer: swapOffer.buyer, 576 | seller: swapOffer.seller, 577 | uuid: swapOffer.uuid, 578 | expiredAt: swapOffer.expiredAt, 579 | status: swapOffer.status, 580 | buyerInscription: swapOffer.buyerSwapInscription.map( 581 | (inscription) => 582 | batchInscriptionInfo[inscription.inscription.inscriptionId], 583 | ), 584 | sellerInscription: swapOffer.sellerSwapInscription.map( 585 | (inscription) => 586 | batchInscriptionInfo[inscription.inscription.inscriptionId], 587 | ), 588 | }; 589 | }); 590 | 591 | const pageMetaDto = new PageMetaDto({ 592 | itemCount, 593 | pageOptionsDto: { 594 | skip: getOfferDto.skip, 595 | order: getOfferDto.order, 596 | page: getOfferDto.page, 597 | take: getOfferDto.take, 598 | }, 599 | }); 600 | 601 | return new PageDto(entities, pageMetaDto); 602 | } 603 | 604 | @Cron(CronExpression.EVERY_MINUTE) 605 | async deleteExpiredOffers() { 606 | await this.swapOfferRepository.update( 607 | { 608 | expiredAt: LessThan(new Date()), 609 | status: In([ 610 | OfferStatus.CREATED, 611 | OfferStatus.SIGNED, 612 | OfferStatus.ACCEPTED, 613 | OfferStatus.FAILED, 614 | ]), 615 | }, 616 | { 617 | status: OfferStatus.EXPIRED, 618 | }, 619 | ); 620 | } 621 | 622 | @Cron(CronExpression.EVERY_MINUTE) 623 | async checkTx() { 624 | const pendingTxs = await this.swapOfferRepository.find({ 625 | where: { 626 | status: OfferStatus.PENDING, 627 | }, 628 | }); 629 | 630 | await Promise.all( 631 | pendingTxs.map(async (tx) => { 632 | const isPushed = await this.checkTxIsPushed(tx.txId); 633 | 634 | await this.swapOfferRepository.update( 635 | { 636 | txId: tx.txId, 637 | }, 638 | { 639 | status: OfferStatus.PUSHED, 640 | }, 641 | ); 642 | return isPushed; 643 | }), 644 | ); 645 | } 646 | 647 | async checkTxIsPushed(txId: string): Promise { 648 | const url = 649 | this.network === testnet 650 | ? `https://mempool.space/testnet/api/tx/${txId}` 651 | : `https://mempool.space/api/tx/${txId}`; 652 | 653 | const res = await axios.get(url); 654 | 655 | return res.data.status.confirmed; 656 | } 657 | 658 | async getUserPushedOffers( 659 | userAddress: string, 660 | getOfferDto: GetUserHistoryDto, 661 | ) { 662 | const user = await this.userService.findByAddress(userAddress); 663 | 664 | const status = getOfferDto.status 665 | ? [getOfferDto.status] 666 | : ['canceled', 'pending', 'pushed', 'expired', 'failed']; 667 | 668 | const [swapOfferIds, itemCount] = 669 | await this.swapOfferRepository.findAndCount({ 670 | select: { 671 | id: true, 672 | updatedAt: true, 673 | }, 674 | where: [ 675 | { 676 | seller: { 677 | id: user.id, 678 | }, 679 | status: In(status), 680 | buyerSwapInscription: { 681 | inscription: { inscriptionId: getOfferDto.keyword }, 682 | }, 683 | }, 684 | { 685 | buyer: { 686 | id: user.id, 687 | }, 688 | status: In(status), 689 | buyerSwapInscription: { 690 | inscription: { inscriptionId: getOfferDto.keyword }, 691 | }, 692 | }, 693 | { 694 | seller: { id: user.id }, 695 | status: In(status), 696 | sellerSwapInscription: { 697 | inscription: { inscriptionId: getOfferDto.keyword }, 698 | }, 699 | }, 700 | { 701 | buyer: { id: user.id }, 702 | status: In(status), 703 | sellerSwapInscription: { 704 | inscription: { inscriptionId: getOfferDto.keyword }, 705 | }, 706 | }, 707 | { 708 | seller: { 709 | id: user.id, 710 | }, 711 | status: In(status), 712 | buyer: { address: getOfferDto.keyword }, 713 | }, 714 | { 715 | buyer: { 716 | id: user.id, 717 | }, 718 | status: In(status), 719 | seller: { address: getOfferDto.keyword }, 720 | }, 721 | ], 722 | relations: { 723 | buyerSwapInscription: { 724 | inscription: { collection: true }, 725 | }, 726 | sellerSwapInscription: { 727 | inscription: { collection: true }, 728 | }, 729 | seller: true, 730 | buyer: true, 731 | }, 732 | skip: getOfferDto.skip ?? (getOfferDto.page - 1) * getOfferDto.take, 733 | take: getOfferDto.take, 734 | order: { 735 | updatedAt: 'DESC', 736 | }, 737 | }); 738 | 739 | const swapOffers = await this.swapOfferRepository.find({ 740 | select: { 741 | seller: { 742 | address: true, 743 | }, 744 | buyer: { 745 | address: true, 746 | }, 747 | }, 748 | where: { 749 | id: In(swapOfferIds.map((inscription) => inscription.id)), 750 | }, 751 | relations: { 752 | buyerSwapInscription: { 753 | inscription: { collection: true }, 754 | }, 755 | sellerSwapInscription: { 756 | inscription: { collection: true }, 757 | }, 758 | seller: true, 759 | buyer: true, 760 | }, 761 | order: { 762 | updatedAt: 'DESC', 763 | }, 764 | }); 765 | 766 | const inscriptionIds: string[] = []; 767 | 768 | swapOffers.forEach((swapOffer) => { 769 | swapOffer.buyerSwapInscription.forEach((inscription) => 770 | inscriptionIds.push(inscription.inscription.inscriptionId), 771 | ); 772 | swapOffer.sellerSwapInscription.forEach((inscription) => 773 | inscriptionIds.push(inscription.inscription.inscriptionId), 774 | ); 775 | }); 776 | 777 | const batchInscriptionInfo = 778 | await this.psbtService.getBatchInscriptionInfoBIS(inscriptionIds); 779 | 780 | const entities = swapOffers.map((swapOffer) => { 781 | const pushedAt = 782 | swapOffer.status === OfferStatus.PUSHED 783 | ? { pushedAt: swapOffer.updatedAt } 784 | : {}; 785 | 786 | return { 787 | uuid: swapOffer.uuid, 788 | price: swapOffer.price, 789 | txId: swapOffer.txId, 790 | status: swapOffer.status, 791 | expiredAt: swapOffer.expiredAt, 792 | buyerInscription: swapOffer.buyerSwapInscription.map((inscription) => { 793 | return { 794 | inscription: { 795 | ...batchInscriptionInfo[inscription.inscription.inscriptionId], 796 | collection: { 797 | name: inscription.inscription.collection.name, 798 | imgUrl: inscription.inscription.collection.imgUrl, 799 | description: inscription.inscription.collection.description, 800 | discord: inscription.inscription.collection.discord, 801 | website: inscription.inscription.collection.website, 802 | twitter: inscription.inscription.collection.twitter, 803 | }, 804 | }, 805 | }; 806 | }), 807 | sellerInscription: swapOffer.sellerSwapInscription.map( 808 | (inscription) => { 809 | return { 810 | inscription: { 811 | ...batchInscriptionInfo[inscription.inscription.inscriptionId], 812 | collection: { 813 | name: inscription.inscription.collection.name, 814 | imgUrl: inscription.inscription.collection.imgUrl, 815 | description: inscription.inscription.collection.description, 816 | discord: inscription.inscription.collection.discord, 817 | website: inscription.inscription.collection.website, 818 | twitter: inscription.inscription.collection.twitter, 819 | }, 820 | }, 821 | }; 822 | }, 823 | ), 824 | buyer: swapOffer.buyer, 825 | seller: swapOffer.seller, 826 | ...pushedAt, 827 | }; 828 | }); 829 | 830 | const pageMetaDto = new PageMetaDto({ 831 | itemCount, 832 | pageOptionsDto: { 833 | skip: getOfferDto.skip, 834 | order: getOfferDto.order, 835 | page: getOfferDto.page, 836 | take: getOfferDto.take, 837 | }, 838 | }); 839 | 840 | return new PageDto(entities, pageMetaDto); 841 | } 842 | 843 | async getPushedOffers(getOfferDto: GetOfferDto) { 844 | const [swapOfferIds, itemCount] = 845 | await this.swapOfferRepository.findAndCount({ 846 | select: { 847 | id: true, 848 | updatedAt: true, 849 | }, 850 | where: [ 851 | { 852 | status: OfferStatus.PUSHED, 853 | buyerSwapInscription: { 854 | inscription: { inscriptionId: getOfferDto.keyword }, 855 | }, 856 | }, 857 | { 858 | status: OfferStatus.PUSHED, 859 | sellerSwapInscription: { 860 | inscription: { inscriptionId: getOfferDto.keyword }, 861 | }, 862 | }, 863 | { 864 | status: OfferStatus.PUSHED, 865 | buyer: { address: getOfferDto.keyword }, 866 | }, 867 | { 868 | status: OfferStatus.PUSHED, 869 | seller: { address: getOfferDto.keyword }, 870 | }, 871 | ], 872 | relations: { 873 | buyerSwapInscription: { 874 | inscription: { collection: true }, 875 | }, 876 | sellerSwapInscription: { 877 | inscription: { collection: true }, 878 | }, 879 | seller: true, 880 | buyer: true, 881 | }, 882 | skip: getOfferDto.skip ?? (getOfferDto.page - 1) * getOfferDto.take, 883 | take: getOfferDto.take, 884 | order: { 885 | updatedAt: 'DESC', 886 | }, 887 | }); 888 | 889 | const swapOffers = await this.swapOfferRepository.find({ 890 | select: { 891 | seller: { 892 | address: true, 893 | }, 894 | buyer: { 895 | address: true, 896 | }, 897 | }, 898 | where: { 899 | id: In(swapOfferIds.map((inscription) => inscription.id)), 900 | }, 901 | relations: { 902 | buyerSwapInscription: { 903 | inscription: { collection: true }, 904 | }, 905 | sellerSwapInscription: { 906 | inscription: { collection: true }, 907 | }, 908 | seller: true, 909 | buyer: true, 910 | }, 911 | order: { 912 | updatedAt: 'DESC', 913 | }, 914 | }); 915 | 916 | const inscriptionIds: string[] = []; 917 | 918 | swapOffers.forEach((swapOffer) => { 919 | swapOffer.buyerSwapInscription.forEach((inscription) => 920 | inscriptionIds.push(inscription.inscription.inscriptionId), 921 | ); 922 | swapOffer.sellerSwapInscription.forEach((inscription) => 923 | inscriptionIds.push(inscription.inscription.inscriptionId), 924 | ); 925 | }); 926 | 927 | const batchInscriptionInfo = 928 | await this.psbtService.getBatchInscriptionInfoBIS(inscriptionIds); 929 | 930 | const entities = swapOffers.map((swapOffer) => { 931 | return { 932 | uuid: swapOffer.uuid, 933 | price: swapOffer.price, 934 | txId: swapOffer.txId, 935 | status: swapOffer.status, 936 | pushedAt: swapOffer.updatedAt, 937 | buyerInscription: swapOffer.buyerSwapInscription.map((inscription) => { 938 | return { 939 | inscription: { 940 | ...batchInscriptionInfo[inscription.inscription.inscriptionId], 941 | collection: { 942 | name: inscription.inscription.collection.name, 943 | imgUrl: inscription.inscription.collection.imgUrl, 944 | description: inscription.inscription.collection.description, 945 | discord: inscription.inscription.collection.discord, 946 | website: inscription.inscription.collection.website, 947 | twitter: inscription.inscription.collection.twitter, 948 | }, 949 | }, 950 | }; 951 | }), 952 | sellerInscription: swapOffer.sellerSwapInscription.map( 953 | (inscription) => { 954 | return { 955 | inscription: { 956 | ...batchInscriptionInfo[inscription.inscription.inscriptionId], 957 | collection: { 958 | name: inscription.inscription.collection.name, 959 | imgUrl: inscription.inscription.collection.imgUrl, 960 | description: inscription.inscription.collection.description, 961 | discord: inscription.inscription.collection.discord, 962 | website: inscription.inscription.collection.website, 963 | twitter: inscription.inscription.collection.twitter, 964 | }, 965 | }, 966 | }; 967 | }, 968 | ), 969 | buyer: swapOffer.buyer, 970 | seller: swapOffer.seller, 971 | }; 972 | }); 973 | 974 | const pageMetaDto = new PageMetaDto({ 975 | itemCount, 976 | pageOptionsDto: { 977 | skip: getOfferDto.skip, 978 | order: getOfferDto.order, 979 | page: getOfferDto.page, 980 | take: getOfferDto.take, 981 | }, 982 | }); 983 | 984 | return new PageDto(entities, pageMetaDto); 985 | } 986 | 987 | async getPushedOffersForSupportCollections() { 988 | const swapOffers = await this.swapOfferRepository.find({ 989 | select: { 990 | seller: { 991 | address: true, 992 | }, 993 | buyer: { 994 | address: true, 995 | }, 996 | }, 997 | where: [ 998 | { 999 | status: OfferStatus.PUSHED, 1000 | sellerSwapInscription: { 1001 | inscription: { 1002 | collectionId: And(LessThan(5), Not(1)), 1003 | }, 1004 | }, 1005 | }, 1006 | { 1007 | status: OfferStatus.PUSHED, 1008 | buyerSwapInscription: { 1009 | inscription: { 1010 | collectionId: And(LessThan(5), Not(1)), 1011 | }, 1012 | }, 1013 | }, 1014 | ], 1015 | relations: { 1016 | buyerSwapInscription: { 1017 | inscription: { collection: true }, 1018 | }, 1019 | sellerSwapInscription: { 1020 | inscription: { collection: true }, 1021 | }, 1022 | seller: true, 1023 | buyer: true, 1024 | }, 1025 | take: 10, 1026 | order: { 1027 | updatedAt: 'DESC', 1028 | }, 1029 | }); 1030 | 1031 | const inscriptionIds: string[] = []; 1032 | 1033 | swapOffers.forEach((swapOffer) => { 1034 | swapOffer.buyerSwapInscription.forEach((inscription) => 1035 | inscriptionIds.push(inscription.inscription.inscriptionId), 1036 | ); 1037 | swapOffer.sellerSwapInscription.forEach((inscription) => 1038 | inscriptionIds.push(inscription.inscription.inscriptionId), 1039 | ); 1040 | }); 1041 | 1042 | const batchInscriptionInfo = 1043 | await this.psbtService.getBatchInscriptionInfoBIS(inscriptionIds); 1044 | 1045 | const entities = swapOffers.map((swapOffer) => { 1046 | return { 1047 | uuid: swapOffer.uuid, 1048 | price: swapOffer.price, 1049 | txId: swapOffer.txId, 1050 | status: swapOffer.status, 1051 | pushedAt: swapOffer.updatedAt, 1052 | buyerInscription: swapOffer.buyerSwapInscription.map((inscription) => { 1053 | const inscriptionInfo = 1054 | batchInscriptionInfo[inscription.inscription.inscriptionId]; 1055 | 1056 | return { 1057 | inscription: { 1058 | ...inscriptionInfo, 1059 | collection: { 1060 | name: inscription.inscription.collection.name, 1061 | imgUrl: inscription.inscription.collection.imgUrl, 1062 | description: inscription.inscription.collection.description, 1063 | discord: inscription.inscription.collection.discord, 1064 | website: inscription.inscription.collection.website, 1065 | twitter: inscription.inscription.collection.twitter, 1066 | }, 1067 | }, 1068 | }; 1069 | }), 1070 | sellerInscription: swapOffer.sellerSwapInscription.map( 1071 | (inscription) => { 1072 | const inscriptionInfo = 1073 | batchInscriptionInfo[inscription.inscription.inscriptionId]; 1074 | 1075 | return { 1076 | inscription: { 1077 | ...inscriptionInfo, 1078 | collection: { 1079 | name: inscription.inscription.collection.name, 1080 | imgUrl: inscription.inscription.collection.imgUrl, 1081 | description: inscription.inscription.collection.description, 1082 | discord: inscription.inscription.collection.discord, 1083 | website: inscription.inscription.collection.website, 1084 | twitter: inscription.inscription.collection.twitter, 1085 | }, 1086 | }, 1087 | }; 1088 | }, 1089 | ), 1090 | buyer: swapOffer.buyer, 1091 | seller: swapOffer.seller, 1092 | }; 1093 | }); 1094 | 1095 | return entities; 1096 | } 1097 | 1098 | async getSwapOfferById(uuid: string): Promise { 1099 | const swapOffer = await this.swapOfferRepository.findOne({ 1100 | select: { 1101 | seller: { 1102 | address: true, 1103 | }, 1104 | buyer: { 1105 | address: true, 1106 | }, 1107 | }, 1108 | where: { uuid }, 1109 | relations: { 1110 | buyerSwapInscription: { 1111 | inscription: { collection: true }, 1112 | }, 1113 | sellerSwapInscription: { 1114 | inscription: { collection: true }, 1115 | }, 1116 | seller: true, 1117 | buyer: true, 1118 | }, 1119 | }); 1120 | 1121 | if (!swapOffer) throw new BadRequestException('Can not find swap offer'); 1122 | 1123 | const pushedAt = 1124 | swapOffer.status === OfferStatus.PUSHED 1125 | ? { pushedAt: swapOffer.updatedAt } 1126 | : {}; 1127 | 1128 | return { 1129 | uuid: swapOffer.uuid, 1130 | psbt: swapOffer.psbt, 1131 | txId: swapOffer.txId, 1132 | buyer: swapOffer.buyer, 1133 | seller: swapOffer.seller, 1134 | expiredAt: swapOffer.expiredAt, 1135 | price: swapOffer.price, 1136 | status: swapOffer.status, 1137 | buyerSwapInscription: await Promise.all( 1138 | swapOffer.buyerSwapInscription.map(async (inscription) => { 1139 | const inscriptionInfo = await this.psbtService.getInscriptionWithUtxo( 1140 | inscription.inscription.inscriptionId, 1141 | ); 1142 | 1143 | return { 1144 | inscription: { 1145 | ...inscriptionInfo, 1146 | collection: { 1147 | name: inscription.inscription.collection.name, 1148 | imgUrl: inscription.inscription.collection.imgUrl, 1149 | description: inscription.inscription.collection.description, 1150 | website: inscription.inscription.collection.website, 1151 | discord: inscription.inscription.collection.discord, 1152 | twitter: inscription.inscription.collection.twitter, 1153 | }, 1154 | }, 1155 | }; 1156 | }), 1157 | ), 1158 | sellerSwapInscription: await Promise.all( 1159 | swapOffer.sellerSwapInscription.map(async (inscription) => { 1160 | const inscriptionInfo = await this.psbtService.getInscriptionWithUtxo( 1161 | inscription.inscription.inscriptionId, 1162 | ); 1163 | 1164 | return { 1165 | inscription: { 1166 | ...inscriptionInfo, 1167 | collection: { 1168 | name: inscription.inscription.collection.name, 1169 | imgUrl: inscription.inscription.collection.imgUrl, 1170 | description: inscription.inscription.collection.description, 1171 | website: inscription.inscription.collection.website, 1172 | discord: inscription.inscription.collection.discord, 1173 | twitter: inscription.inscription.collection.twitter, 1174 | }, 1175 | }, 1176 | }; 1177 | }), 1178 | ), 1179 | ...pushedAt, 1180 | }; 1181 | } 1182 | } 1183 | --------------------------------------------------------------------------------