- Intégration mempool.space pour vérification transactions Bitcoin : - Service MempoolSpaceService avec API mempool.space - Vérification sorties et montants pour sponsoring - Vérification confirmations - Attente confirmation avec polling - Récupération adresses Lightning depuis profils Nostr : - Service LightningAddressService - Support lud16 et lud06 (NIP-19) - Cache avec TTL 1 heure - Intégré dans paymentPolling et reviewReward - Mise à jour événements Nostr pour avis rémunérés : - Publication événement avec tags rewarded et reward_amount - Parsing tags dans parseReviewFromEvent - Vérification doublons - Tracking sponsoring sur Nostr : - Service SponsoringTrackingService - Événements avec commissions et confirmations - Intégration vérification mempool.space Toutes les fonctionnalités de split sont maintenant opérationnelles. Seuls les transferts Lightning réels nécessitent un nœud Lightning.
291 lines
8.8 KiB
TypeScript
291 lines
8.8 KiB
TypeScript
import { getAlbyService } from './alby'
|
|
import { calculateReviewSplit, PLATFORM_COMMISSIONS } from './platformCommissions'
|
|
import { automaticTransferService } from './automaticTransfer'
|
|
import { platformTracking } from './platformTracking'
|
|
import { nostrService } from './nostr'
|
|
import { lightningAddressService } from './lightningAddress'
|
|
import { getReviewsForArticle } from './reviews'
|
|
import type { AlbyInvoice } from '@/types/alby'
|
|
import type { Event } from 'nostr-tools'
|
|
|
|
/**
|
|
* Review reward service
|
|
* Handles Lightning payments for rewarding reviews with automatic commission split
|
|
*
|
|
* Review reward: 70 sats total
|
|
* - Reviewer: 49 sats
|
|
* - Platform: 21 sats
|
|
*/
|
|
export interface ReviewRewardRequest {
|
|
reviewId: string
|
|
articleId: string
|
|
reviewerPubkey: string
|
|
reviewerLightningAddress?: string
|
|
authorPubkey: string
|
|
authorPrivateKey: string
|
|
}
|
|
|
|
export interface ReviewRewardResult {
|
|
success: boolean
|
|
invoice?: AlbyInvoice
|
|
paymentHash?: string
|
|
error?: string
|
|
split: {
|
|
reviewer: number
|
|
platform: number
|
|
total: number
|
|
}
|
|
}
|
|
|
|
export class ReviewRewardService {
|
|
/**
|
|
* Create review reward payment with commission split
|
|
*/
|
|
async createReviewRewardPayment(request: ReviewRewardRequest): Promise<ReviewRewardResult> {
|
|
try {
|
|
const split = calculateReviewSplit()
|
|
|
|
// Verify author has permission to reward this review
|
|
// (should be verified before calling this function)
|
|
|
|
const alby = getAlbyService()
|
|
await alby.enable()
|
|
|
|
const invoice = await alby.createInvoice({
|
|
amount: split.total,
|
|
description: `Review reward: ${request.reviewId} (${split.reviewer} sats to reviewer, ${split.platform} sats commission)`,
|
|
expiry: 3600, // 1 hour
|
|
})
|
|
|
|
console.log('Review reward invoice created', {
|
|
reviewId: request.reviewId,
|
|
articleId: request.articleId,
|
|
reviewerPubkey: request.reviewerPubkey,
|
|
amount: split.total,
|
|
reviewerPortion: split.reviewer,
|
|
platformCommission: split.platform,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
|
|
return {
|
|
success: true,
|
|
invoice,
|
|
paymentHash: invoice.paymentHash,
|
|
split,
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
|
console.error('Error creating review reward payment', {
|
|
reviewId: request.reviewId,
|
|
articleId: request.articleId,
|
|
error: errorMessage,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
split: calculateReviewSplit(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process review reward payment after confirmation
|
|
* Transfers reviewer portion and tracks commission
|
|
*/
|
|
async processReviewRewardPayment(
|
|
request: ReviewRewardRequest,
|
|
paymentHash: string
|
|
): Promise<boolean> {
|
|
try {
|
|
const split = calculateReviewSplit()
|
|
|
|
// Get reviewer Lightning address if not provided
|
|
let reviewerLightningAddress = request.reviewerLightningAddress
|
|
if (!reviewerLightningAddress) {
|
|
reviewerLightningAddress = await lightningAddressService.getLightningAddress(request.reviewerPubkey)
|
|
}
|
|
|
|
// Transfer reviewer portion
|
|
if (reviewerLightningAddress) {
|
|
const transferResult = await automaticTransferService.transferReviewerPortion(
|
|
reviewerLightningAddress,
|
|
request.reviewId,
|
|
request.reviewerPubkey,
|
|
split.total
|
|
)
|
|
|
|
if (!transferResult.success) {
|
|
console.error('Failed to transfer reviewer portion', {
|
|
reviewId: request.reviewId,
|
|
error: transferResult.error,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
// Continue anyway - transfer can be done manually later
|
|
}
|
|
} else {
|
|
console.warn('Reviewer Lightning address not available for automatic transfer', {
|
|
reviewId: request.reviewId,
|
|
reviewerPubkey: request.reviewerPubkey,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
}
|
|
|
|
// Track the reward payment
|
|
await this.trackReviewReward(request, split, paymentHash)
|
|
|
|
// Update review event with reward tag
|
|
await this.updateReviewWithReward(request.reviewId, request.authorPrivateKey)
|
|
|
|
console.log('Review reward processed', {
|
|
reviewId: request.reviewId,
|
|
articleId: request.articleId,
|
|
reviewerPubkey: request.reviewerPubkey,
|
|
reviewerAmount: split.reviewer,
|
|
platformCommission: split.platform,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
|
|
return true
|
|
} catch (error) {
|
|
console.error('Error processing review reward payment', {
|
|
reviewId: request.reviewId,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Track review reward payment
|
|
*/
|
|
private async trackReviewReward(
|
|
request: ReviewRewardRequest,
|
|
split: { reviewer: number; platform: number; total: number },
|
|
paymentHash: string
|
|
): Promise<void> {
|
|
try {
|
|
// In production, publish tracking event on Nostr similar to article payments
|
|
console.log('Review reward tracked', {
|
|
reviewId: request.reviewId,
|
|
articleId: request.articleId,
|
|
reviewerPubkey: request.reviewerPubkey,
|
|
authorPubkey: request.authorPubkey,
|
|
reviewerAmount: split.reviewer,
|
|
platformCommission: split.platform,
|
|
paymentHash,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
} catch (error) {
|
|
console.error('Error tracking review reward', {
|
|
reviewId: request.reviewId,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update review event with reward tag
|
|
* Publishes a new event that references the original review with reward tags
|
|
*/
|
|
private async updateReviewWithReward(reviewId: string, authorPrivateKey: string): Promise<void> {
|
|
try {
|
|
const pool = nostrService.getPool()
|
|
if (!pool) {
|
|
throw new Error('Pool not initialized')
|
|
}
|
|
|
|
// Get the original event from pool
|
|
const poolWithSub = pool as import('@/types/nostr-tools-extended').SimplePoolWithSub
|
|
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
|
const filters = [
|
|
{
|
|
kinds: [1],
|
|
ids: [reviewId],
|
|
limit: 1,
|
|
},
|
|
]
|
|
|
|
const originalEvent = await new Promise<Event | null>((resolve) => {
|
|
let resolved = false
|
|
const sub = poolWithSub.sub([RELAY_URL], filters)
|
|
|
|
const finalize = (value: Event | null) => {
|
|
if (resolved) {
|
|
return
|
|
}
|
|
resolved = true
|
|
sub.unsub()
|
|
resolve(value)
|
|
}
|
|
|
|
sub.on('event', (event: Event) => {
|
|
finalize(event)
|
|
})
|
|
|
|
sub.on('eose', () => finalize(null))
|
|
setTimeout(() => finalize(null), 5000)
|
|
})
|
|
|
|
if (!originalEvent) {
|
|
console.error('Original review event not found', {
|
|
reviewId,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Check if already rewarded
|
|
const alreadyRewarded = originalEvent.tags.some((tag) => tag[0] === 'rewarded' && tag[1] === 'true')
|
|
if (alreadyRewarded) {
|
|
console.log('Review already marked as rewarded', {
|
|
reviewId,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Create updated event with reward tags
|
|
nostrService.setPrivateKey(authorPrivateKey)
|
|
nostrService.setPublicKey(originalEvent.pubkey)
|
|
|
|
const updatedEvent = {
|
|
kind: 1,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
...originalEvent.tags.filter((tag) => tag[0] !== 'rewarded' && tag[0] !== 'reward_amount'),
|
|
['e', reviewId], // Reference to original review
|
|
['rewarded', 'true'],
|
|
['reward_amount', PLATFORM_COMMISSIONS.review.total.toString()],
|
|
],
|
|
content: originalEvent.content, // Keep original content
|
|
}
|
|
|
|
const publishedEvent = await nostrService.publishEvent(updatedEvent)
|
|
|
|
if (publishedEvent) {
|
|
console.log('Review updated with reward tag', {
|
|
reviewId,
|
|
updatedEventId: publishedEvent.id,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
} else {
|
|
console.error('Failed to publish updated review event', {
|
|
reviewId,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating review with reward', {
|
|
reviewId,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
export const reviewRewardService = new ReviewRewardService()
|