- 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.
265 lines
8.0 KiB
TypeScript
265 lines
8.0 KiB
TypeScript
import { nostrService } from './nostr'
|
|
import { articlePublisher } from './articlePublisher'
|
|
import { getStoredPrivateContent } from './articleStorage'
|
|
import { platformTracking } from './platformTracking'
|
|
import { calculateArticleSplit, PLATFORM_COMMISSIONS } from './platformCommissions'
|
|
import { automaticTransferService } from './automaticTransfer'
|
|
import { lightningAddressService } from './lightningAddress'
|
|
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
|
|
|
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
|
|
|
/**
|
|
* Poll for payment completion via zap receipt verification
|
|
* After payment is confirmed, sends private content to the user
|
|
*/
|
|
async function pollPaymentUntilDeadline(
|
|
articleId: string,
|
|
articlePubkey: string,
|
|
amount: number,
|
|
recipientPubkey: string,
|
|
interval: number,
|
|
deadline: number
|
|
): Promise<boolean> {
|
|
try {
|
|
const zapReceiptExists = await nostrService.checkZapReceipt(articlePubkey, articleId, amount, recipientPubkey)
|
|
if (zapReceiptExists) {
|
|
const zapReceiptId = await getZapReceiptId(articlePubkey, articleId, amount, recipientPubkey)
|
|
await sendPrivateContentAfterPayment(articleId, recipientPubkey, amount, zapReceiptId)
|
|
return true
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking zap receipt:', error)
|
|
}
|
|
|
|
if (Date.now() > deadline) {
|
|
return false
|
|
}
|
|
|
|
return new Promise<boolean>((resolve) => {
|
|
setTimeout(() => {
|
|
void pollPaymentUntilDeadline(articleId, articlePubkey, amount, recipientPubkey, interval, deadline)
|
|
.then(resolve)
|
|
.catch(() => resolve(false))
|
|
}, interval)
|
|
})
|
|
}
|
|
|
|
export async function waitForArticlePayment(
|
|
_paymentHash: string,
|
|
articleId: string,
|
|
articlePubkey: string,
|
|
amount: number,
|
|
recipientPubkey: string,
|
|
timeout: number = 300000 // 5 minutes
|
|
): Promise<boolean> {
|
|
const interval = 2000
|
|
const deadline = Date.now() + timeout
|
|
try {
|
|
return await pollPaymentUntilDeadline(articleId, articlePubkey, amount, recipientPubkey, interval, deadline)
|
|
} catch (error) {
|
|
console.error('Wait for payment error:', error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function getZapReceiptId(
|
|
articlePubkey: string,
|
|
articleId: string,
|
|
amount: number,
|
|
recipientPubkey: string
|
|
): Promise<string | undefined> {
|
|
try {
|
|
const pool = nostrService.getPool()
|
|
if (!pool) {
|
|
return undefined
|
|
}
|
|
|
|
const filters = [
|
|
{
|
|
kinds: [9735],
|
|
'#p': [articlePubkey],
|
|
'#e': [articleId],
|
|
limit: 1,
|
|
},
|
|
]
|
|
|
|
return new Promise((resolve) => {
|
|
let resolved = false
|
|
const poolWithSub = pool as import('@/types/nostr-tools-extended').SimplePoolWithSub
|
|
const sub = poolWithSub.sub([RELAY_URL], filters)
|
|
|
|
const finalize = (value: string | undefined) => {
|
|
if (resolved) {
|
|
return
|
|
}
|
|
resolved = true
|
|
sub.unsub()
|
|
resolve(value)
|
|
}
|
|
|
|
sub.on('event', (event) => {
|
|
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
|
|
const amountInSats = amountTag ? Math.floor(parseInt(amountTag, 10) / 1000) : 0
|
|
if (amountInSats === amount && event.pubkey === recipientPubkey) {
|
|
finalize(event.id)
|
|
}
|
|
})
|
|
|
|
sub.on('eose', () => finalize(undefined))
|
|
setTimeout(() => finalize(undefined), 3000)
|
|
})
|
|
} catch (error) {
|
|
console.error('Error getting zap receipt ID', {
|
|
articleId,
|
|
recipientPubkey,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
})
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send private content to user after payment confirmation
|
|
* Returns true if content was successfully sent and verified
|
|
*/
|
|
async function sendPrivateContentAfterPayment(
|
|
articleId: string,
|
|
recipientPubkey: string,
|
|
amount: number,
|
|
zapReceiptId?: string
|
|
): Promise<boolean> {
|
|
const storedContent = await getStoredPrivateContent(articleId)
|
|
|
|
if (!storedContent) {
|
|
console.error('Stored private content not found for article', {
|
|
articleId,
|
|
recipientPubkey,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
return false
|
|
}
|
|
|
|
const authorPrivateKey = nostrService.getPrivateKey()
|
|
|
|
if (!authorPrivateKey) {
|
|
console.error('Author private key not available, cannot send private content automatically', {
|
|
articleId,
|
|
recipientPubkey,
|
|
authorPubkey: storedContent.authorPubkey,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
return false
|
|
}
|
|
|
|
const result = await articlePublisher.sendPrivateContent(articleId, recipientPubkey, authorPrivateKey)
|
|
|
|
if (result.success && result.messageEventId) {
|
|
const timestamp = Math.floor(Date.now() / 1000)
|
|
|
|
// Verify payment amount matches expected commission structure
|
|
const expectedSplit = calculateArticleSplit()
|
|
if (amount !== expectedSplit.total) {
|
|
console.error('Payment amount does not match expected commission structure', {
|
|
articleId,
|
|
paidAmount: amount,
|
|
expectedTotal: expectedSplit.total,
|
|
expectedAuthor: expectedSplit.author,
|
|
expectedPlatform: expectedSplit.platform,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
}
|
|
|
|
// Track content delivery with commission information
|
|
await platformTracking.trackContentDelivery(
|
|
{
|
|
articleId,
|
|
articlePubkey: storedContent.authorPubkey,
|
|
recipientPubkey,
|
|
messageEventId: result.messageEventId,
|
|
amount,
|
|
authorAmount: expectedSplit.author,
|
|
platformCommission: expectedSplit.platform,
|
|
timestamp,
|
|
verified: result.verified ?? false,
|
|
zapReceiptId,
|
|
},
|
|
authorPrivateKey
|
|
)
|
|
|
|
// Log commission information for platform tracking
|
|
console.log('Article payment processed with commission', {
|
|
articleId,
|
|
totalAmount: amount,
|
|
authorPortion: expectedSplit.author,
|
|
platformCommission: expectedSplit.platform,
|
|
recipientPubkey,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
|
|
// Trigger automatic transfer of author portion
|
|
try {
|
|
// Get author's Lightning address from profile
|
|
const authorLightningAddress = await lightningAddressService.getLightningAddress(storedContent.authorPubkey)
|
|
|
|
if (authorLightningAddress) {
|
|
const transferResult = await automaticTransferService.transferAuthorPortion(
|
|
authorLightningAddress,
|
|
articleId,
|
|
storedContent.authorPubkey,
|
|
amount
|
|
)
|
|
|
|
if (!transferResult.success) {
|
|
console.warn('Automatic transfer failed, will be retried later', {
|
|
articleId,
|
|
authorPubkey: storedContent.authorPubkey,
|
|
error: transferResult.error,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
}
|
|
} else {
|
|
console.warn('Author Lightning address not available for automatic transfer', {
|
|
articleId,
|
|
authorPubkey: storedContent.authorPubkey,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
// Transfer will need to be done manually later
|
|
}
|
|
} catch (error) {
|
|
console.error('Error triggering automatic transfer', {
|
|
articleId,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
// Don't fail the payment process if transfer fails
|
|
}
|
|
|
|
if (result.verified) {
|
|
console.log('Private content sent and verified on relay', {
|
|
articleId,
|
|
recipientPubkey,
|
|
messageEventId: result.messageEventId,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
return true
|
|
} else {
|
|
console.warn('Private content sent but not yet verified on relay', {
|
|
articleId,
|
|
recipientPubkey,
|
|
messageEventId: result.messageEventId,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
return true
|
|
}
|
|
} else {
|
|
console.error('Failed to send private content, but payment was confirmed', {
|
|
articleId,
|
|
recipientPubkey,
|
|
error: result.error,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
return false
|
|
}
|
|
}
|