diff --git a/.github/workflows/publish-nostr.yml b/.github/workflows/publish-nostr.yml new file mode 100644 index 0000000..f735cec --- /dev/null +++ b/.github/workflows/publish-nostr.yml @@ -0,0 +1,69 @@ +name: publish-nostr + +on: + push: + paths: + - apps/nostr/** + - libs/** + - package.json + - bun.lockb + - .github/workflows/publish-nostr.yml + workflow_dispatch: + +jobs: + testing: + uses: ./.github/workflows/wait-for-tests.yml + with: + test-job-name: test + + docker: + needs: testing + runs-on: ubuntu-latest + steps: + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v4 + with: + images: | + bitsacco/nostr + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - name: Login to Docker Hub + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + uses: docker/login-action@v2 + with: + username: okjodom + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Build and push nostr + uses: docker/build-push-action@v4 + with: + file: apps/nostr/Dockerfile + push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Checkout repository content + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + uses: actions/checkout@v4 + + # This workflow requires the repository content to be locally available to read the README + - name: Update the Docker Hub description + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + uses: peter-evans/dockerhub-description@v3 + with: + username: okjodom + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + repository: bitsacco/nostr + readme-filepath: ./apps/nostr/README.md diff --git a/apps/api/src/api.module.ts b/apps/api/src/api.module.ts index 62f1749..6a8f1bd 100644 --- a/apps/api/src/api.module.ts +++ b/apps/api/src/api.module.ts @@ -6,10 +6,13 @@ import { ClientsModule, Transport } from '@nestjs/microservices'; import { EVENTS_SERVICE_BUS, LoggerModule, + NOSTR_PACKAGE_NAME, + NOSTR_SERVICE_NAME, SWAP_PACKAGE_NAME, SWAP_SERVICE_NAME, } from '@bitsacco/common'; import { SwapController, SwapService } from './swap'; +import { NostrController, NostrService } from './nostr'; @Module({ imports: [ @@ -37,6 +40,18 @@ import { SwapController, SwapService } from './swap'; }), inject: [ConfigService], }, + { + name: NOSTR_SERVICE_NAME, + useFactory: (configService: ConfigService) => ({ + transport: Transport.GRPC, + options: { + package: NOSTR_PACKAGE_NAME, + protoPath: join(__dirname, '../../../proto/nostr.proto'), + url: configService.getOrThrow('NOSTR_GRPC_URL'), + }, + }), + inject: [ConfigService], + }, { name: EVENTS_SERVICE_BUS, useFactory: (configService: ConfigService) => ({ @@ -50,7 +65,7 @@ import { SwapController, SwapService } from './swap'; }, ]), ], - controllers: [SwapController], - providers: [SwapService], + controllers: [SwapController, NostrController], + providers: [SwapService, NostrService], }) export class ApiModule {} diff --git a/apps/api/src/nostr/index.ts b/apps/api/src/nostr/index.ts new file mode 100644 index 0000000..f9e4248 --- /dev/null +++ b/apps/api/src/nostr/index.ts @@ -0,0 +1,2 @@ +export * from './nostr.controller'; +export * from './nostr.service'; diff --git a/apps/api/src/nostr/nostr.controller.spec.ts b/apps/api/src/nostr/nostr.controller.spec.ts new file mode 100644 index 0000000..80ef032 --- /dev/null +++ b/apps/api/src/nostr/nostr.controller.spec.ts @@ -0,0 +1,33 @@ +import { TestingModule } from '@nestjs/testing'; +import { createTestingModuleWithValidation } from '@bitsacco/testing'; + +import { NostrController } from './nostr.controller'; +import { NostrService } from './nostr.service'; + +describe('NostrController', () => { + let controller: NostrController; + let nostrService: NostrService; + + beforeEach(async () => { + const module: TestingModule = await createTestingModuleWithValidation({ + controllers: [NostrController], + providers: [ + { + provide: NostrService, + useValue: { + sendEncryptedNostrDm: jest.fn(), + configureNostrRelays: jest.fn(), + }, + }, + ], + }); + + controller = module.get(NostrController); + nostrService = module.get(NostrService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + expect(nostrService).toBeDefined(); + }); +}); diff --git a/apps/api/src/nostr/nostr.controller.ts b/apps/api/src/nostr/nostr.controller.ts new file mode 100644 index 0000000..2a50145 --- /dev/null +++ b/apps/api/src/nostr/nostr.controller.ts @@ -0,0 +1,34 @@ +import { ApiOperation, ApiBody } from '@nestjs/swagger'; +import { Body, Controller, Logger, Post } from '@nestjs/common'; +import { + ConfigureNostrRelaysDto, + SendEncryptedNostrDmDto, +} from '@bitsacco/common'; +import { NostrService } from './nostr.service'; + +@Controller('nostr') +export class NostrController { + private readonly logger = new Logger(NostrController.name); + + constructor(private readonly nostrService: NostrService) { + this.logger.log('NostrController initialized'); + } + + @Post('relays') + @ApiOperation({ summary: 'Configure nostr relays' }) + @ApiBody({ + type: ConfigureNostrRelaysDto, + }) + configureNostrRelays(@Body() req: ConfigureNostrRelaysDto) { + return this.nostrService.configureNostrRelays(req); + } + + @Post('dm') + @ApiOperation({ summary: 'Send encrypted nostr dm' }) + @ApiBody({ + type: SendEncryptedNostrDmDto, + }) + send(@Body() req: SendEncryptedNostrDmDto) { + return this.nostrService.sendEncryptedNostrDm(req); + } +} diff --git a/apps/api/src/nostr/nostr.service.spec.ts b/apps/api/src/nostr/nostr.service.spec.ts new file mode 100644 index 0000000..5c89d02 --- /dev/null +++ b/apps/api/src/nostr/nostr.service.spec.ts @@ -0,0 +1,35 @@ +import { TestingModule } from '@nestjs/testing'; +import { ClientGrpc } from '@nestjs/microservices'; +import { NostrServiceClient } from '@bitsacco/common'; +import { NostrService } from './nostr.service'; +import { createTestingModuleWithValidation } from '@bitsacco/testing'; + +describe('NostrService', () => { + let service: NostrService; + let serviceGenerator: ClientGrpc; + let mockNostrServiceClient: Partial; + + beforeEach(async () => { + serviceGenerator = { + getService: jest.fn().mockReturnValue(mockNostrServiceClient), + getClientByServiceName: jest.fn().mockReturnValue(mockNostrServiceClient), + }; + + const module: TestingModule = await createTestingModuleWithValidation({ + providers: [ + { + provide: NostrService, + useFactory: () => { + return new NostrService(serviceGenerator); + }, + }, + ], + }); + + service = module.get(NostrService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/api/src/nostr/nostr.service.ts b/apps/api/src/nostr/nostr.service.ts new file mode 100644 index 0000000..65f4049 --- /dev/null +++ b/apps/api/src/nostr/nostr.service.ts @@ -0,0 +1,27 @@ +import { + ConfigureNostrRelaysDto, + NOSTR_SERVICE_NAME, + NostrServiceClient, + SendEncryptedNostrDmDto, +} from '@bitsacco/common'; +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import { type ClientGrpc } from '@nestjs/microservices'; + +@Injectable() +export class NostrService implements OnModuleInit { + private client: NostrServiceClient; + + constructor(@Inject(NOSTR_SERVICE_NAME) private readonly grpc: ClientGrpc) {} + + onModuleInit() { + this.client = this.grpc.getService(NOSTR_SERVICE_NAME); + } + + sendEncryptedNostrDm(req: SendEncryptedNostrDmDto) { + return this.client.sendEncryptedNostrDirectMessage(req); + } + + configureNostrRelays(req: ConfigureNostrRelaysDto) { + return this.client.configureTrustedNostrRelays(req); + } +} diff --git a/apps/api/src/swap/swap.controller.spec.ts b/apps/api/src/swap/swap.controller.spec.ts index 9755c0d..18cc32d 100644 --- a/apps/api/src/swap/swap.controller.spec.ts +++ b/apps/api/src/swap/swap.controller.spec.ts @@ -5,10 +5,10 @@ import { SupportedCurrencies, } from '@bitsacco/common'; import { createTestingModuleWithValidation } from '@bitsacco/testing'; +import { ClientProxy } from '@nestjs/microservices'; import { SwapController } from './swap.controller'; import { SwapService } from './swap.service'; -import { ClientProxy } from '@nestjs/microservices'; describe('SwapController', () => { let controller: SwapController; diff --git a/apps/nostr/Dockerfile b/apps/nostr/Dockerfile new file mode 100644 index 0000000..ca68a13 --- /dev/null +++ b/apps/nostr/Dockerfile @@ -0,0 +1,32 @@ +FROM oven/bun:latest AS development + +WORKDIR /usr/src/app + +COPY package.json ./ +COPY bun.lockb ./ +COPY tsconfig.json tsconfig.json +COPY nest-cli.json nest-cli.json + +COPY apps/nostr apps/nostr +COPY libs libs +COPY proto proto + +RUN bun install +RUN bun build:nostr + +FROM oven/bun:latest AS production + +ARG NODE_ENV=production +ENV NODE_ENV=${NODE_ENV} + +WORKDIR /usr/src/app + +COPY package.json ./ +COPY bun.lockb ./ + +RUN bun install --production + +COPY --from=development /usr/src/app/dist ./dist +COPY --from=development /usr/src/app/proto ./proto + +CMD ["sh", "-c", "bun run dist/apps/nostr/main.js"] diff --git a/apps/nostr/README.md b/apps/nostr/README.md new file mode 100644 index 0000000..cdc54b0 --- /dev/null +++ b/apps/nostr/README.md @@ -0,0 +1,17 @@ +# apps/nostr + +This app is a gRPC microservice for simple nostr operations with Bitsacco + +## Dev + +Run `bun dev nostr` to launch the microservice in development mode. +Run `bun start` to launch this plus any other microservice and the REST api gateway in dev mode + +## Docs + +- See [nostr.proto](https://github.com/bitsacco/os/blob/main/proto/nostr.proto) +- With the microservice running, see supported gRPC methods with type reflection at http://localhost:4050 + +## Architecture + +See [architecture.md](https://github.com/bitsacco/os/blob/main/docs/architecture.md) diff --git a/apps/nostr/src/main.ts b/apps/nostr/src/main.ts new file mode 100644 index 0000000..508e0a6 --- /dev/null +++ b/apps/nostr/src/main.ts @@ -0,0 +1,33 @@ +import { join } from 'path'; +import { Logger } from 'nestjs-pino'; +import { NestFactory } from '@nestjs/core'; +import { ConfigService } from '@nestjs/config'; +import { MicroserviceOptions, Transport } from '@nestjs/microservices'; +import { ReflectionService } from '@grpc/reflection'; +import { NostrModule } from './nostr.module'; + +async function bootstrap() { + const app = await NestFactory.create(NostrModule); + + const configService = app.get(ConfigService); + + const nostr_url = configService.getOrThrow('NOSTR_GRPC_URL'); + const nostr = app.connectMicroservice({ + transport: Transport.GRPC, + options: { + package: 'nostr', + url: nostr_url, + protoPath: join(__dirname, '../../../proto/nostr.proto'), + onLoadPackageDefinition: (pkg, server) => { + new ReflectionService(pkg).addToServer(server); + }, + }, + }); + + // setup pino logging + app.useLogger(app.get(Logger)); + + await app.startAllMicroservices(); +} + +bootstrap(); diff --git a/apps/nostr/src/nostr.controller.spec.ts b/apps/nostr/src/nostr.controller.spec.ts new file mode 100644 index 0000000..73401ff --- /dev/null +++ b/apps/nostr/src/nostr.controller.spec.ts @@ -0,0 +1,28 @@ +import { TestingModule } from '@nestjs/testing'; +import { createTestingModuleWithValidation } from '@bitsacco/testing'; +import { NostrController } from './nostr.controller'; +import { NostrService } from './nostr.service'; + +describe('NostrController', () => { + let nostrController: NostrController; + let nostrService: NostrService; + + beforeEach(async () => { + const app: TestingModule = await createTestingModuleWithValidation({ + imports: [], + controllers: [NostrController], + providers: [ + { + provide: NostrService, + useValue: { + sendEncryptedDirectMessage: jest.fn(), + configureNostrRelays: jest.fn(), + }, + }, + ], + }); + + nostrController = app.get(NostrController); + nostrService = app.get(NostrService); + }); +}); diff --git a/apps/nostr/src/nostr.controller.ts b/apps/nostr/src/nostr.controller.ts new file mode 100644 index 0000000..9044c67 --- /dev/null +++ b/apps/nostr/src/nostr.controller.ts @@ -0,0 +1,24 @@ +import { Controller } from '@nestjs/common'; +import { GrpcMethod } from '@nestjs/microservices'; +import { + ConfigureNostrRelaysDto, + NostrServiceControllerMethods, + SendEncryptedNostrDmDto, +} from '@bitsacco/common'; +import { NostrService } from './nostr.service'; + +@Controller() +@NostrServiceControllerMethods() +export class NostrController { + constructor(private readonly nostrService: NostrService) {} + + @GrpcMethod() + configureTrustedNostrRelays(request: ConfigureNostrRelaysDto) { + return this.nostrService.configureNostrRelays(request); + } + + @GrpcMethod() + sendEncryptedNostrDirectMessage(request: SendEncryptedNostrDmDto) { + return this.nostrService.sendEncryptedDirectMessage(request); + } +} diff --git a/apps/nostr/src/nostr.module.ts b/apps/nostr/src/nostr.module.ts new file mode 100644 index 0000000..4a9017f --- /dev/null +++ b/apps/nostr/src/nostr.module.ts @@ -0,0 +1,24 @@ +import * as Joi from 'joi'; +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { LoggerModule } from '@bitsacco/common'; +import { NostrController } from './nostr.controller'; +import { NostrService } from './nostr.service'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + validationSchema: Joi.object({ + NODE_ENV: Joi.string().required(), + NOSTR_GRPC_URL: Joi.string().required(), + NOSTR_PUBLIC_KEY: Joi.string().required(), + NOSTR_PRIVATE_KEY: Joi.string().required(), + }), + }), + LoggerModule, + ], + controllers: [NostrController], + providers: [NostrService, ConfigService], +}) +export class NostrModule {} diff --git a/apps/nostr/src/nostr.service.spec.ts b/apps/nostr/src/nostr.service.spec.ts new file mode 100644 index 0000000..bdce536 --- /dev/null +++ b/apps/nostr/src/nostr.service.spec.ts @@ -0,0 +1,42 @@ +import { TestingModule } from '@nestjs/testing'; +import { createTestingModuleWithValidation } from '@bitsacco/testing'; +import { NostrService } from './nostr.service'; +import { ConfigService } from '@nestjs/config'; + +describe('NostrService', () => { + let service: NostrService; + let mockCfg: jest.Mocked = { + getOrThrow: jest.fn(), + } as any; + + beforeEach(async () => { + mockCfg = { + getOrThrow: jest.fn().mockImplementation((key) => { + switch (key) { + case 'NOSTR_PUBLIC_KEY': + return 'c26253da9951d363833474e9145fa15b56876e532cf138251f9b59ea993de6a7'; + case 'NOSTR_PRIVATE_KEY': + return 'nsec1yxpf8nqa7fq4vp8qkfwllpcdjkh3n2apsqhw4ee6hrn8yfg62d9sfegrqa'; + default: + throw new Error('unknown config'); + } + }), + } as any; + + const module: TestingModule = await createTestingModuleWithValidation({ + providers: [ + { + provide: ConfigService, + useValue: mockCfg, + }, + NostrService, + ], + }); + + service = module.get(NostrService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/nostr/src/nostr.service.ts b/apps/nostr/src/nostr.service.ts new file mode 100644 index 0000000..45b4639 --- /dev/null +++ b/apps/nostr/src/nostr.service.ts @@ -0,0 +1,143 @@ +import NDK, { + NDKEvent, + NDKPrivateKeySigner, + NDKUser, +} from '@nostr-dev-kit/ndk'; +import { nip19 } from 'nostr-tools'; +import { ConfigService } from '@nestjs/config'; +import { Injectable, Logger } from '@nestjs/common'; +import { + ConfigureNostrRelaysDto, + NostrRecipient, + SendEncryptedNostrDmDto, +} from '@bitsacco/common'; + +const explicitRelayUrls = [ + 'wss://relay.damus.io', + 'wss://relay.nostr.bg', + 'wss://relay.snort.social', +]; + +@Injectable() +export class NostrService { + private readonly logger = new Logger(NostrService.name); + private readonly ndk: NDK; + private readonly pubkey: string; + private connected = false; + + constructor(private readonly configService: ConfigService) { + this.logger.log('NostrService created'); + + const privkey = this.configService.getOrThrow('NOSTR_PRIVATE_KEY'); + this.pubkey = this.configService.getOrThrow('NOSTR_PUBLIC_KEY'); + + const signer = new NDKPrivateKeySigner(privkey); + this.ndk = new NDK({ + explicitRelayUrls, + signer, + }); + + this.connectRelays() + .then(() => { + this.logger.log('NostrService connected'); + this.connected = true; + }) + .catch((e) => { + this.logger.warn('NostrService disconnected'); + this.connected = false; + }); + } + + private async connectRelays() { + try { + await this.ndk.connect(); + // Wait one sec for connections to stabilize + await new Promise((resolve) => setTimeout(resolve, 1000)); + this.logger.log( + `${this.ndk.pool.connectedRelays().length} relays connected`, + ); + } catch (e) { + this.logger.error(`Failed to connect nostr relays, ${e}`); + throw e; + } + } + + private async publishEventWithRetry(dm: NDKEvent, maxAttempts: number = 2) { + let attempts = 0; + while (attempts < maxAttempts) { + try { + await dm.publish(); + this.logger.log('Published Nostr event'); + return; + } catch (error) { + attempts++; + if (attempts >= maxAttempts) { + throw new Error( + `Failed to publish Nostr event after ${maxAttempts} attempts: ${error}`, + ); + } + this.logger.warn(`Publish attempt ${attempts} failed. Retrying...`); + await new Promise((resolve) => setTimeout(resolve, 1000 * attempts)); + } + } + } + + private parseRecipient(recipient: NostrRecipient): string { + let target: string = ''; + if (!recipient) { + this.logger.log(`Recipient undefined. Notifying self`); + target = this.pubkey; + } + + const { npub, pubkey } = recipient; + + // todo: validate npub + if (npub && npub.startsWith('npub')) { + const { type, data } = nip19.decode(npub); + if (type === 'npub') { + target = data as string; + } + } + + if (pubkey) { + // todo: validate pubkey + target = pubkey; + } + return target; + } + + async sendEncryptedDirectMessage({ + message, + recipient, + retry, + }: SendEncryptedNostrDmDto): Promise { + try { + if (!this.connected) { + await this.connectRelays(); + } + + const receiver = this.parseRecipient(recipient) || this.pubkey; + + const dm = new NDKEvent(this.ndk, { + kind: 4, + content: message, + tags: [['p']], + created_at: Math.floor(Date.now() / 1000), + pubkey: this.pubkey, + }); + + const user = new NDKUser({ pubkey: receiver }); + dm.encrypt(user); + + return this.publishEventWithRetry(dm, retry ? 5 : 0); + } catch (error) { + this.logger.error(error); + return; + } + } + + async configureNostrRelays(req: ConfigureNostrRelaysDto): Promise { + this.logger.log(req); + return; + } +} diff --git a/apps/nostr/test/app.e2e-spec.ts b/apps/nostr/test/app.e2e-spec.ts new file mode 100644 index 0000000..0158322 --- /dev/null +++ b/apps/nostr/test/app.e2e-spec.ts @@ -0,0 +1,24 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { NostrModule } from './../src/nostr.module'; + +describe('NostrController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [NostrModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => { + return request(app.getHttpServer()) + .get('/') + .expect(200) + .expect('Hello World!'); + }); +}); diff --git a/apps/nostr/test/jest-e2e.json b/apps/nostr/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/apps/nostr/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/apps/nostr/tsconfig.app.json b/apps/nostr/tsconfig.app.json new file mode 100644 index 0000000..535fd88 --- /dev/null +++ b/apps/nostr/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": false, + "outDir": "../../dist/apps/nostr" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/apps/swap/src/fx/fx.service.ts b/apps/swap/src/fx/fx.service.ts index a242f18..b721abc 100644 --- a/apps/swap/src/fx/fx.service.ts +++ b/apps/swap/src/fx/fx.service.ts @@ -101,7 +101,10 @@ export class FxService { baseCurrency: Currency, targetCurrency: Currency, ) { - const rate = await this.getExchangeRate(mapToSupportedCurrency(baseCurrency), mapToSupportedCurrency(targetCurrency)); + const rate = await this.getExchangeRate( + mapToSupportedCurrency(baseCurrency), + mapToSupportedCurrency(targetCurrency), + ); return 1 / rate; } } diff --git a/bun.lockb b/bun.lockb index 22fc63f..7a841e0 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/compose.yml b/compose.yml index 442c4ab..6f36441 100644 --- a/compose.yml +++ b/compose.yml @@ -38,6 +38,28 @@ services: volumes: - .:/usr/src/app + nostr: + container_name: nostr + build: + context: . + dockerfile: ./apps/nostr/Dockerfile + target: development + command: [ + "sh", + "-c", + "bun dev nostr" + ] + restart: always + depends_on: + - mongodb + - redis + env_file: + - ./apps/nostr/.env + ports: + - '4050:4050' + volumes: + - .:/usr/src/app + mongodb: image: mongo:7.0-jammy container_name: mongodb diff --git a/libs/common/src/dto/configure-nostr-relays.dto.ts b/libs/common/src/dto/configure-nostr-relays.dto.ts new file mode 100644 index 0000000..1f6b914 --- /dev/null +++ b/libs/common/src/dto/configure-nostr-relays.dto.ts @@ -0,0 +1,40 @@ +import { + IsBoolean, + IsString, + IsNotEmpty, + IsDefined, + ValidateNested, + Matches, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; +import { ConfigureNostrRelaysRequest, NostrRelay } from '../types'; + +class NostrRelayDto implements NostrRelay { + @IsNotEmpty() + @IsString() + @Matches(/^wss?:\/\/.+/, { + message: 'Socket must be a valid WebSocket URL (ws:// or wss://)', + }) + @Type(() => String) + @ApiProperty() + socket: string; + + @IsBoolean() + @Type(() => Boolean) + @ApiProperty() + read: boolean; + + @IsBoolean() + @Type(() => Boolean) + @ApiProperty() + write: boolean; +} + +export class ConfigureNostrRelaysDto implements ConfigureNostrRelaysRequest { + @IsDefined() + @ValidateNested({ each: true }) + @Type(() => NostrRelayDto) + @ApiProperty({ type: [NostrRelayDto] }) + relays: NostrRelayDto[]; +} diff --git a/libs/common/src/dto/index.ts b/libs/common/src/dto/index.ts index ea20963..7f801ef 100644 --- a/libs/common/src/dto/index.ts +++ b/libs/common/src/dto/index.ts @@ -1,5 +1,9 @@ +// swap export * from './find-swap.dto'; export * from './list-swaps.dto'; export * from './create-onramp-swap.dto'; export * from './create-offramp-swap.dto'; export * from './quote.dto'; +// nostr +export * from './configure-nostr-relays.dto'; +export * from './send-encrypted-nostr-dm.dto'; diff --git a/libs/common/src/dto/send-encrypted-nostr-dm.dto.ts b/libs/common/src/dto/send-encrypted-nostr-dm.dto.ts new file mode 100644 index 0000000..438aaaf --- /dev/null +++ b/libs/common/src/dto/send-encrypted-nostr-dm.dto.ts @@ -0,0 +1,44 @@ +import { + IsBoolean, + IsString, + IsNotEmpty, + IsDefined, + ValidateNested, + IsOptional, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; +import { NostrDirectMessageRequest, NostrRecipient } from '../types'; + +class NostrRecipientDto implements NostrRecipient { + @IsOptional() + @IsString() + @Type(() => String) + @ApiProperty() + npub: string; + + @IsOptional() + @IsString() + @Type(() => String) + @ApiProperty() + pubkey: string; +} + +export class SendEncryptedNostrDmDto implements NostrDirectMessageRequest { + @IsNotEmpty() + @IsString() + @Type(() => String) + @ApiProperty() + message: string; + + @IsDefined() + @ValidateNested() + @Type(() => NostrRecipientDto) + @ApiProperty({ type: NostrRecipientDto }) + recipient: NostrRecipientDto; + + @IsBoolean() + @Type(() => Boolean) + @ApiProperty() + retry: boolean; +} diff --git a/libs/common/src/types/index.ts b/libs/common/src/types/index.ts index 4139358..2d63384 100644 --- a/libs/common/src/types/index.ts +++ b/libs/common/src/types/index.ts @@ -1,4 +1,5 @@ export * from './proto/swap'; +export * from './proto/nostr'; export * from './api'; export * from './validator'; export * from './fedimint'; diff --git a/libs/common/src/types/proto/nostr.ts b/libs/common/src/types/proto/nostr.ts new file mode 100644 index 0000000..6a8ef7f --- /dev/null +++ b/libs/common/src/types/proto/nostr.ts @@ -0,0 +1,88 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.2.7 +// protoc v3.21.12 +// source: nostr.proto + +/* eslint-disable */ +import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices'; +import { Observable } from 'rxjs'; + +export interface Empty {} + +export interface ConfigureNostrRelaysRequest { + relays: NostrRelay[]; +} + +export interface NostrDirectMessageRequest { + message: string; + recipient: NostrRecipient | undefined; + retry: boolean; +} + +export interface NostrRecipient { + npub?: string | undefined; + pubkey?: string | undefined; +} + +export interface NostrRelay { + socket: string; + read: boolean; + write: boolean; +} + +export const NOSTR_PACKAGE_NAME = 'nostr'; + +export interface NostrServiceClient { + configureTrustedNostrRelays( + request: ConfigureNostrRelaysRequest, + ): Observable; + + sendEncryptedNostrDirectMessage( + request: NostrDirectMessageRequest, + ): Observable; +} + +export interface NostrServiceController { + configureTrustedNostrRelays( + request: ConfigureNostrRelaysRequest, + ): Promise | Observable | Empty; + + sendEncryptedNostrDirectMessage( + request: NostrDirectMessageRequest, + ): Promise | Observable | Empty; +} + +export function NostrServiceControllerMethods() { + return function (constructor: Function) { + const grpcMethods: string[] = [ + 'configureTrustedNostrRelays', + 'sendEncryptedNostrDirectMessage', + ]; + for (const method of grpcMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor( + constructor.prototype, + method, + ); + GrpcMethod('NostrService', method)( + constructor.prototype[method], + method, + descriptor, + ); + } + const grpcStreamMethods: string[] = []; + for (const method of grpcStreamMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor( + constructor.prototype, + method, + ); + GrpcStreamMethod('NostrService', method)( + constructor.prototype[method], + method, + descriptor, + ); + } + }; +} + +export const NOSTR_SERVICE_NAME = 'NostrService'; diff --git a/libs/common/src/types/proto/swap.ts b/libs/common/src/types/proto/swap.ts index 01573f1..1f6cf00 100644 --- a/libs/common/src/types/proto/swap.ts +++ b/libs/common/src/types/proto/swap.ts @@ -1,6 +1,6 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: -// protoc-gen-ts_proto v2.2.4 +// protoc-gen-ts_proto v2.2.7 // protoc v3.21.12 // source: swap.proto @@ -26,9 +26,6 @@ export enum SwapStatus { UNRECOGNIZED = -1, } -/** Empty: Represents an empty message. */ -export interface Empty {} - /** QuoteRequest: Represents a request for a currency swap quote. */ export interface QuoteRequest { /** Currency to swap from */ diff --git a/nest-cli.json b/nest-cli.json index 8128d2f..69a8640 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -5,7 +5,9 @@ "compilerOptions": { "deleteOutDir": true, "webpack": true, - "assets": ["**/*.proto"], + "assets": [ + "**/*.proto" + ], "watchAssets": true }, "projects": { @@ -35,6 +37,15 @@ "compilerOptions": { "tsConfigPath": "apps/swap/tsconfig.app.json" } + }, + "nostr": { + "type": "application", + "root": "apps/nostr", + "entryFile": "main", + "sourceRoot": "apps/nostr/src", + "compilerOptions": { + "tsConfigPath": "apps/nostr/tsconfig.app.json" + } } }, "monorepo": true, diff --git a/package.json b/package.json index 0492c1c..779c01d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build": "nest build", "build:api": "bun run build api", "build:swap": "bun run build swap", + "build:nostr": "bun run build nostr", "format": "prettier --write \"apps/**/*.ts\" \"libs/**/*.ts\"", "lint": "bun run eslint \"{apps,libs,test}/**/*.ts\" --fix", "test": "bun test", @@ -20,7 +21,7 @@ "test:cov": "bun test --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "bun test apps/api/test/ --config ./apps/api/test/jest-e2e.json", - "proto:gen": "protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./libs/common/src/types/proto --ts_proto_opt=nestJs=true -I ./proto ./proto/*.proto", + "proto:gen": "protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./libs/common/src/types/proto --ts_proto_opt=nestJs=true --ts_proto_opt=eslint_disable -I ./proto ./proto/*.proto", "proto:clean": "rm ./libs/common/src/types/proto/*.ts" }, "dependencies": { @@ -38,6 +39,7 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/sequelize": "^10.0.1", "@nestjs/swagger": "^7.4.2", + "@nostr-dev-kit/ndk": "^2.10.6", "cache-manager": "4.1.0", "cache-manager-redis-store": "^3.0.1", "class-transformer": "^0.5.1", @@ -48,6 +50,7 @@ "joi": "^17.13.3", "mongoose": "^8.8.0", "nestjs-pino": "^4.1.0", + "nostr-tools": "^2.10.3", "pino-http": "^10.3.0", "pino-pretty": "^11.3.0", "redis": "^4.7.0", @@ -107,4 +110,4 @@ "^@bitsacco/testing(|/.*)$": "/libs/testing/src/$1" } } -} +} \ No newline at end of file diff --git a/proto/nostr.proto b/proto/nostr.proto new file mode 100644 index 0000000..8ba49b0 --- /dev/null +++ b/proto/nostr.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package nostr; + +service NostrService { + rpc ConfigureTrustedNostrRelays (ConfigureNostrRelaysRequest) returns (Empty) {} + + rpc SendEncryptedNostrDirectMessage (NostrDirectMessageRequest) returns (Empty) {} +} + +message Empty {} + +message ConfigureNostrRelaysRequest { + repeated NostrRelay relays = 1; +} + +message NostrDirectMessageRequest { + string message = 1; + + NostrRecipient recipient = 2; + + bool retry = 3; +} + +message NostrRecipient { + optional string npub = 1; + + optional string pubkey = 2; +} + +message NostrRelay { + string socket = 1; + + bool read = 2; + + bool write = 3; +} diff --git a/proto/swap.proto b/proto/swap.proto index 3de2472..d670479 100644 --- a/proto/swap.proto +++ b/proto/swap.proto @@ -26,9 +26,6 @@ service SwapService { rpc ListOfframpSwaps (PaginatedRequest) returns (PaginatedSwapResponse) {} } -// Empty: Represents an empty message. -message Empty {} - // QuoteRequest: Represents a request for a currency swap quote. message QuoteRequest { // Currency to swap from