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