mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
- 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
7 changed files with 314 additions and 1 deletions
@ -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 |
|||
); |
|||
} |
|||
} |
|||
} |
|||
@ -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 {} |
|||
@ -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; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue