Browse Source

feat: Add PayTheFly crypto payment gateway integration

- Add PayTheFly payment service with EIP-712 signing support
- Add webhook controller with HMAC-SHA256 signature verification
- Add PayTheFly NestJS module integrated into subscription module
- Support BSC (chainId=56) and TRON (chainId=728126428)
- Use timing-safe comparison for webhook signatures
- Human-readable amounts (not raw token units)
- All secrets loaded from environment variables
- Keccak-256 only (never SHA3-256)
pull/6445/head
paythefly-develop 1 month ago
parent
commit
1a73688a3e
  1. 7
      .env.example
  2. 94
      apps/api/src/app/subscription/paythefly/paythefly.controller.ts
  3. 14
      apps/api/src/app/subscription/paythefly/paythefly.module.ts
  4. 189
      apps/api/src/app/subscription/paythefly/paythefly.service.ts
  5. 3
      apps/api/src/app/subscription/subscription.module.ts
  6. 4
      apps/api/src/services/configuration/configuration.service.ts
  7. 4
      apps/api/src/services/interfaces/environment.interface.ts

7
.env.example

@ -14,3 +14,10 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING> ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING> JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
# PayTheFly Crypto Payment Integration
# https://paythefly.com - Accept crypto payments on BSC & TRON
PAYTHEFLY_PROJECT_ID=
PAYTHEFLY_PROJECT_KEY=
PAYTHEFLY_PRIVATE_KEY=
PAYTHEFLY_CHAIN_ID=56

94
apps/api/src/app/subscription/paythefly/paythefly.controller.ts

@ -0,0 +1,94 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
Body,
Controller,
HttpCode,
HttpException,
Logger,
Post,
Res
} from '@nestjs/common';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import {
PayTheFlyService,
PayTheFlyWebhookBody
} from './paythefly.service';
/**
* PayTheFly Webhook Controller
*
* Handles incoming webhook notifications from PayTheFly.
*
* Webhook body format:
* {
* "data": "<json string>",
* "sign": "<hmac hex>",
* "timestamp": <unix>
* }
*
* IMPORTANT: Response must contain "success" string for PayTheFly
* to mark the notification as delivered.
*/
@Controller('subscription/paythefly')
export class PayTheFlyController {
private readonly logger = new Logger(PayTheFlyController.name);
public constructor(
private readonly configurationService: ConfigurationService,
private readonly payTheFlyService: PayTheFlyService
) {}
/**
* Handle PayTheFly webhook notification.
*
* Verifies HMAC-SHA256 signature and processes the payment event.
* Response MUST contain "success" for PayTheFly to acknowledge delivery.
*/
@Post('webhook')
@HttpCode(StatusCodes.OK)
public async handleWebhook(
@Body() body: PayTheFlyWebhookBody,
@Res() response: Response
) {
try {
const payload = this.payTheFlyService.parseWebhookPayload(body);
if (this.payTheFlyService.isPaymentConfirmed(payload)) {
this.logger.log(
`PayTheFly payment confirmed: serial_no=${payload.serial_no}, ` +
`value=${payload.value}, tx_hash=${payload.tx_hash}, ` +
`wallet=${payload.wallet}`
);
// TODO: Update subscription status based on serial_no
// await this.subscriptionService.confirmPayment(payload.serial_no);
} else if (payload.tx_type === 2) {
this.logger.log(
`PayTheFly withdrawal event: serial_no=${payload.serial_no}, ` +
`tx_hash=${payload.tx_hash}`
);
} else {
this.logger.warn(
`PayTheFly unconfirmed payment: serial_no=${payload.serial_no}, ` +
`confirmed=${payload.confirmed}`
);
}
// PayTheFly requires response to contain "success"
return response.send('success');
} catch (error) {
this.logger.error(
`PayTheFly webhook error: ${error.message}`,
error.stack
);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

14
apps/api/src/app/subscription/paythefly/paythefly.module.ts

@ -0,0 +1,14 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { Module } from '@nestjs/common';
import { PayTheFlyController } from './paythefly.controller';
import { PayTheFlyService } from './paythefly.service';
@Module({
controllers: [PayTheFlyController],
exports: [PayTheFlyService],
imports: [ConfigurationModule],
providers: [PayTheFlyService]
})
export class PayTheFlyModule {}

189
apps/api/src/app/subscription/paythefly/paythefly.service.ts

@ -0,0 +1,189 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Injectable, Logger } from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'crypto';
/**
* PayTheFly Crypto Payment Service
*
* Handles crypto payment integration with PayTheFly for BSC and TRON chains.
* Uses EIP-712 typed structured data signing for payment authentication.
*
* Supported chains:
* - BSC (chainId=56, 18 decimals)
* - TRON (chainId=728126428, 6 decimals)
*
* IMPORTANT: Uses Keccak-256 for hashing, NEVER SHA3-256.
*/
/**
* PayTheFly webhook body format.
*
* @example
* {
* "data": "{\"serial_no\":\"...\",\"value\":\"10.00\",...}",
* "sign": "hmac_hex_signature",
* "timestamp": 1709312400
* }
*/
export interface PayTheFlyWebhookBody {
data: string; // JSON stringified payload
sign: string; // HMAC-SHA256 hex signature
timestamp: number; // Unix timestamp
}
/**
* PayTheFly webhook payload (decoded from data field).
*
* Note: Uses 'value' not 'amount', and 'confirmed' not 'status'.
*/
export interface PayTheFlyWebhookPayload {
value: string; // Payment amount (NOT "amount")
confirmed: boolean; // Payment confirmed (NOT "status")
serial_no: string; // Order serial number
tx_hash: string; // Blockchain transaction hash
wallet: string; // Payer's wallet address
tx_type: number; // 1=payment, 2=withdrawal
}
export interface PayTheFlyPaymentUrl {
url: string;
serialNo: string;
deadline: number;
}
@Injectable()
export class PayTheFlyService {
private readonly logger = new Logger(PayTheFlyService.name);
public constructor(
private readonly configurationService: ConfigurationService
) {}
/**
* Generate a PayTheFly payment URL.
*
* Payment link format:
* https://pro.paythefly.com/pay?chainId=56&projectId=xxx&amount=0.01&serialNo=xxx&deadline=xxx&signature=0x...&token=0x...
*
* @param amount Human-readable amount (e.g., "10.00"), NOT raw token units
* @param serialNo Unique order reference
* @param tokenAddress Token contract address
*/
public generatePaymentUrl({
amount,
serialNo,
tokenAddress
}: {
amount: string;
serialNo: string;
tokenAddress: string;
}): PayTheFlyPaymentUrl {
const projectId = this.configurationService.get('PAYTHEFLY_PROJECT_ID');
const chainId = this.configurationService.get('PAYTHEFLY_CHAIN_ID');
const deadline = Math.floor(Date.now() / 1000) + 1800; // 30 min
// Note: The EIP-712 signature must be generated server-side
// using the private key. The actual signing requires ethers.js
// or a similar library with EIP-712 support.
//
// EIP-712 Domain: { name: 'PayTheFlyPro', version: '1' }
// PaymentRequest struct: { projectId, token, amount, serialNo, deadline }
const params = new URLSearchParams({
chainId: String(chainId),
projectId,
amount, // Human-readable, NOT raw units
serialNo,
deadline: String(deadline),
token: tokenAddress
// signature: '0x...' — Must be appended after EIP-712 signing
});
return {
url: `https://pro.paythefly.com/pay?${params.toString()}`,
serialNo,
deadline
};
}
/**
* Verify a PayTheFly webhook signature.
*
* Signature: HMAC-SHA256(data + "." + timestamp, projectKey)
* Uses timing-safe comparison to prevent timing attacks.
*
* @throws Error if signature is invalid
*/
public verifyWebhookSignature(body: PayTheFlyWebhookBody): void {
const projectKey = this.configurationService.get('PAYTHEFLY_PROJECT_KEY');
const message = `${body.data}.${body.timestamp}`;
const expectedSign = createHmac('sha256', projectKey)
.update(message)
.digest('hex');
const expectedBuffer = Buffer.from(expectedSign, 'utf-8');
const receivedBuffer = Buffer.from(body.sign, 'utf-8');
// Timing-safe comparison to prevent timing attacks
if (
expectedBuffer.length !== receivedBuffer.length ||
!timingSafeEqual(expectedBuffer, receivedBuffer)
) {
throw new Error('Invalid PayTheFly webhook signature');
}
}
/**
* Parse and validate a PayTheFly webhook payload.
*
* Webhook payload fields:
* - value (NOT "amount")
* - confirmed (NOT "status")
* - serial_no, tx_hash, wallet, tx_type
*
* tx_type: 1=payment, 2=withdrawal
*/
public parseWebhookPayload(
body: PayTheFlyWebhookBody
): PayTheFlyWebhookPayload {
this.verifyWebhookSignature(body);
let payload: PayTheFlyWebhookPayload;
try {
payload = JSON.parse(body.data);
} catch {
throw new Error('Invalid PayTheFly webhook data JSON');
}
// Validate required fields
if (
!payload.serial_no ||
payload.value === undefined ||
payload.confirmed === undefined ||
!payload.tx_hash ||
!payload.wallet ||
payload.tx_type === undefined
) {
throw new Error(
'Missing required fields in PayTheFly webhook payload. ' +
'Expected: serial_no, value, confirmed, tx_hash, wallet, tx_type'
);
}
return payload;
}
/**
* Check if a webhook payload represents a confirmed payment.
*
* A payment is confirmed when:
* - confirmed === true
* - tx_type === 1 (payment, not withdrawal)
*/
public isPaymentConfirmed(payload: PayTheFlyWebhookPayload): boolean {
return payload.confirmed === true && payload.tx_type === 1;
}
}

3
apps/api/src/app/subscription/subscription.module.ts

@ -4,13 +4,14 @@ import { PropertyModule } from '@ghostfolio/api/services/property/property.modul
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PayTheFlyModule } from './paythefly/paythefly.module';
import { SubscriptionController } from './subscription.controller'; import { SubscriptionController } from './subscription.controller';
import { SubscriptionService } from './subscription.service'; import { SubscriptionService } from './subscription.service';
@Module({ @Module({
controllers: [SubscriptionController], controllers: [SubscriptionController],
exports: [SubscriptionService], exports: [SubscriptionService],
imports: [ConfigurationModule, PrismaModule, PropertyModule], imports: [ConfigurationModule, PayTheFlyModule, PrismaModule, PropertyModule],
providers: [SubscriptionService] providers: [SubscriptionService]
}) })
export class SubscriptionModule {} export class SubscriptionModule {}

4
apps/api/src/services/configuration/configuration.service.ts

@ -103,6 +103,10 @@ export class ConfigurationService {
default: environment.rootUrl default: environment.rootUrl
}), }),
STRIPE_SECRET_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }),
PAYTHEFLY_PROJECT_ID: str({ default: '' }),
PAYTHEFLY_PROJECT_KEY: str({ default: '' }),
PAYTHEFLY_PRIVATE_KEY: str({ default: '' }),
PAYTHEFLY_CHAIN_ID: num({ default: 56 }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
TWITTER_API_KEY: str({ default: 'dummyApiKey' }), TWITTER_API_KEY: str({ default: 'dummyApiKey' }),

4
apps/api/src/services/interfaces/environment.interface.ts

@ -53,6 +53,10 @@ export interface Environment extends CleanedEnvAccessors {
REQUEST_TIMEOUT: number; REQUEST_TIMEOUT: number;
ROOT_URL: string; ROOT_URL: string;
STRIPE_SECRET_KEY: string; STRIPE_SECRET_KEY: string;
PAYTHEFLY_PROJECT_ID: string;
PAYTHEFLY_PROJECT_KEY: string;
PAYTHEFLY_PRIVATE_KEY: string;
PAYTHEFLY_CHAIN_ID: number;
TWITTER_ACCESS_TOKEN: string; TWITTER_ACCESS_TOKEN: string;
TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_ACCESS_TOKEN_SECRET: string;
TWITTER_API_KEY: string; TWITTER_API_KEY: string;

Loading…
Cancel
Save