Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 27 additions & 18 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,31 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@stellar/stellar-sdk": "^14.6.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"@nestjs/swagger": "^11.2.6",
"@nestjs/schedule": "^6.1.1",
"@stellar-pay/payments-engine": "workspace:*",
"cron": "^4.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1"
},
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.2.6",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0",
"@stellar-pay/payments-engine": "workspace:*",
"@stellar/stellar-sdk": "^14.6.1",
"bip32": "^4.0.0",
"bitcoinjs-lib": "^7.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cron": "^4.4.0",
"ethers": "^6.13.5",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1",
"tiny-secp256k1": "^2.2.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
Expand Down Expand Up @@ -64,7 +72,8 @@
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
"typescript-eslint": "^8.20.0",
"prisma": "^5.22.0"
},
"jest": {
"moduleFileExtensions": [
Expand Down
22 changes: 9 additions & 13 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,37 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module';
import { TreasuryModule } from './treasury/treasury.module';
import { AuthModule } from './auth/auth.module';
import { PaymentsModule } from './payments/payments.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { ThrottlerRedisGuard } from './rate-limiter/guards/throttler-redis.guard';
import { WorkerModule } from './modules/worker/worker.module';

@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
HealthModule,
TreasuryModule,
AuthModule,
WorkerModule,
PaymentsModule,
ThrottlerModule.forRoot({
throttlers: [
{ name: 'short', ttl: 60000, limit: 100 },
{ name: 'long', ttl: 60000, limit: 1000 },
],
// TODO: Implement Redis storage when Redis service is available
// storage: new ThrottlerStorageRedisService(),
}),
],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: ThrottlerRedisGuard,
},
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: ThrottlerRedisGuard },
],
})
export class AppModule {}
export class AppModule {}
16 changes: 13 additions & 3 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

async function bootstrap() {
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);

app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);

const config = new DocumentBuilder()
.setTitle('Stellar Pay API Documentation')
.setDescription('The API description for stellar pay')
Expand All @@ -18,4 +27,5 @@ async function bootstrap() {

await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

void bootstrap();
5 changes: 5 additions & 0 deletions apps/api/src/modules/worker/payments-engine.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare module '@stellar-pay/payments-engine' {
export class StellarService {
sendFunds(destinationAddress: string, amount: string): Promise<string>;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
import { DepositAddressService } from './deposit-address.service';
import { GenerateDepositAddressDto } from './dto/generate-deposit-address.dto';
import { Chain } from './enums/chain.enum';
import { type DepositAddress } from './interfaces/deposit-address.interface';

@Controller('payments/intents/:intentId/deposit-address')
export class DepositAddressController {
constructor(private readonly depositAddressService: DepositAddressService) {}

@Post()
@HttpCode(HttpStatus.CREATED)
generate(
@Param('intentId') intentId: string,
@Body() dto: GenerateDepositAddressDto,
): DepositAddress {
return this.depositAddressService.generate(intentId, dto);
}

@Get()
findAll(@Param('intentId') intentId: string): DepositAddress[] {
return this.depositAddressService.findAllByIntent(intentId);
}

@Get(':chain')
findOne(@Param('intentId') intentId: string, @Param('chain') chain: Chain): DepositAddress {
return this.depositAddressService.findByIntentAndChain(intentId, chain);
}
}
18 changes: 18 additions & 0 deletions apps/api/src/payments/deposit-address/deposit-address.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { DepositAddressController } from './deposit-address.controller';
import { DepositAddressService } from './deposit-address.service';
import { StellarWalletService } from './wallet/stellar.wallet.service';
import { EthereumWalletService } from './wallet/ethereum.wallet.service';
import { BitcoinWalletService } from './wallet/bitcoin.wallet.service';

@Module({
controllers: [DepositAddressController],
providers: [
DepositAddressService,
StellarWalletService,
EthereumWalletService,
BitcoinWalletService,
],
exports: [DepositAddressService],
})
export class DepositAddressModule {}
94 changes: 94 additions & 0 deletions apps/api/src/payments/deposit-address/deposit-address.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { Chain } from './enums/chain.enum';
import { type DepositAddress } from './interfaces/deposit-address.interface';
import { type GenerateDepositAddressDto } from './dto/generate-deposit-address.dto';
import { StellarWalletService } from './wallet/stellar.wallet.service';
import { EthereumWalletService } from './wallet/ethereum.wallet.service';
import { BitcoinWalletService } from './wallet/bitcoin.wallet.service';

@Injectable()
export class DepositAddressService {
private readonly logger = new Logger(DepositAddressService.name);

private readonly store = new Map<string, DepositAddress>();

private derivationCounter = 0;

constructor(
private readonly stellarWallet: StellarWalletService,
private readonly ethereumWallet: EthereumWalletService,
private readonly bitcoinWallet: BitcoinWalletService,
) {}

generate(paymentIntentId: string, dto: GenerateDepositAddressDto): DepositAddress {
const storeKey = `${paymentIntentId}:${dto.chain}`;

const existing = this.store.get(storeKey);
if (existing) {
return existing;
}

const masterSeed = process.env.HD_MASTER_SEED;
if (!masterSeed) {
throw new Error('HD_MASTER_SEED environment variable is not set');
}

const derivationIndex = this.derivationCounter++;
const address = this.deriveAddress(dto.chain, masterSeed, derivationIndex);

const depositAddress: DepositAddress = {
id: crypto.randomUUID(),
paymentIntentId,
chain: dto.chain,
address: address.address,
memo: address.memo,
derivationIndex,
expiresAt: dto.expiresAt,
createdAt: new Date().toISOString(),
};

this.store.set(storeKey, depositAddress);
this.logger.log(`Generated ${dto.chain} deposit address for intent ${paymentIntentId}`);

return depositAddress;
}

findByIntentAndChain(paymentIntentId: string, chain: Chain): DepositAddress {
const storeKey = `${paymentIntentId}:${chain}`;
const found = this.store.get(storeKey);

if (!found) {
throw new NotFoundException(
`No ${chain} deposit address found for payment intent ${paymentIntentId}`,
);
}

return found;
}

findAllByIntent(paymentIntentId: string): DepositAddress[] {
return [...this.store.values()].filter((d) => d.paymentIntentId === paymentIntentId);
}

private deriveAddress(
chain: Chain,
masterSeed: string,
index: number,
): { address: string; memo?: string } {
switch (chain) {
case Chain.STELLAR: {
return this.stellarWallet.deriveAddress(masterSeed, index);
}
case Chain.ETHEREUM: {
return { address: this.ethereumWallet.deriveAddress(masterSeed, index) };
}
case Chain.BITCOIN: {
return { address: this.bitcoinWallet.deriveAddress(masterSeed, index) };
}
default: {
const _exhaustive: never = chain;
throw new Error(`Unsupported chain: ${String(_exhaustive)}`);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { IsEnum, IsOptional, IsDateString } from 'class-validator';
import { Chain } from '../enums/chain.enum';

export class GenerateDepositAddressDto {
@IsEnum(Chain, {
message: `chain must be one of: ${Object.values(Chain).join(', ')}`,
})
chain!: Chain;

@IsOptional()
@IsDateString()
expiresAt?: string;
}
5 changes: 5 additions & 0 deletions apps/api/src/payments/deposit-address/enums/chain.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum Chain {
STELLAR = 'STELLAR',
BITCOIN = 'BITCOIN',
ETHEREUM = 'ETHEREUM',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Chain } from '../enums/chain.enum';

export interface DepositAddress {
id: string;
paymentIntentId: string;
chain: Chain;
address: string;
memo?: string;
derivationIndex: number;
expiresAt?: string;
createdAt: string;
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import BIP32Factory from 'bip32';
import * as ecc from 'tiny-secp256k1';
import * as bitcoin from 'bitcoinjs-lib';

const bip32 = BIP32Factory(ecc);

@Injectable()
export class BitcoinWalletService {
deriveAddress(masterSeedHex: string, derivationIndex: number): string {
const masterSeed = Buffer.from(masterSeedHex, 'hex');
const derivationPath = `m/44'/0'/0'/0/${derivationIndex}`;
const root = bip32.fromSeed(masterSeed);
const child = root.derivePath(derivationPath);

const { address } = bitcoin.payments.p2pkh({
pubkey: Buffer.from(child.publicKey),
network: bitcoin.networks.bitcoin,
});

if (!address) {
throw new Error(`Failed to derive Bitcoin address at index ${derivationIndex}`);
}

return address;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { HDNodeWallet } from 'ethers';

@Injectable()
export class EthereumWalletService {
deriveAddress(masterSeedHex: string, derivationIndex: number): string {
const masterSeed = Buffer.from(masterSeedHex, 'hex');
const derivationPath = `m/44'/60'/0'/0/${derivationIndex}`;
const wallet = HDNodeWallet.fromSeed(masterSeed).derivePath(derivationPath);
return wallet.address;
}
}
Loading