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
11 changes: 10 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,17 @@
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@stellar/stellar-sdk": "^14.6.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"@prisma/client": "^5.22.0",
"bitcoinjs-lib": "^7.0.0",
"bip32": "^4.0.0",
"@nestjs/config": "^4.0.0",
"ethers": "^6.13.5",
"tiny-secp256k1": "^2.2.3",
"rxjs": "^7.8.1"
},
"devDependencies": {
Expand All @@ -56,7 +64,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
18 changes: 8 additions & 10 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,39 +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';

@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }), // ← loads .env globally
PrismaModule,
HealthModule,
TreasuryModule,
AuthModule,
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 {}
15 changes: 13 additions & 2 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

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

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

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

void bootstrap();
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;
}
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { createHmac } from 'node:crypto';
import { Keypair } from '@stellar/stellar-sdk';

const SLIP10_SEED = Buffer.from('ed25519 seed', 'utf8');

@Injectable()
export class StellarWalletService {
deriveAddress(masterSeedHex: string, derivationIndex: number): { address: string; memo: string } {
const masterSeed = Buffer.from(masterSeedHex, 'hex');

const masterHmac = createHmac('sha512', SLIP10_SEED).update(masterSeed).digest();
let keyBuffer = masterHmac.subarray(0, 32);
let chainCode = masterHmac.subarray(32);

const pathIndices = [0x80000000 + 44, 0x80000000 + 148, 0x80000000 + derivationIndex];

for (const childIndex of pathIndices) {
const data = Buffer.alloc(37);
data[0] = 0x00;
keyBuffer.copy(data, 1);
data.writeUInt32BE(childIndex, 33);

const childHmac = createHmac('sha512', chainCode).update(data).digest();
keyBuffer = childHmac.subarray(0, 32);
chainCode = childHmac.subarray(32);
}

const keypair = Keypair.fromRawEd25519Seed(keyBuffer);

return {
address: keypair.publicKey(),
memo: String(derivationIndex),
};
}
}
19 changes: 19 additions & 0 deletions apps/api/src/payments/dto/create-payment-intent.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { IsEnum, IsNumber, IsObject, IsOptional, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { Currency } from '../enums/currency.enum';

export class CreatePaymentIntentDto {
@IsNumber()
@Min(0.01, { message: 'amount must be at least 0.01' })
@Type(() => Number)
amount!: number;

@IsEnum(Currency, {
message: `currency must be one of: ${Object.values(Currency).join(', ')}`,
})
currency!: Currency;

@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}
5 changes: 5 additions & 0 deletions apps/api/src/payments/enums/currency.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum Currency {
USDC = 'USDC',
EURC = 'EURC',
XLM = 'XLM',
}
19 changes: 19 additions & 0 deletions apps/api/src/payments/payments.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { CurrentMerchant } from '../auth/decorators/current-merchant.decorator';
import { type MerchantUser } from '../auth/interfaces/merchant-user.interface';
import { CreatePaymentIntentDto } from './dto/create-payment-intent.dto';
import { PaymentsService, type PaymentIntent } from './payments.service';

@Controller('payments')
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}

@Post('intents')
@HttpCode(HttpStatus.CREATED)
createPaymentIntent(
@Body() dto: CreatePaymentIntentDto,
@CurrentMerchant() merchant: MerchantUser,
): PaymentIntent {
return this.paymentsService.createPaymentIntent(dto, merchant.merchant_id);
}
}
Loading