ws402
Version:
WebSocket implementation of X402 protocol for pay-as-you-go digital resources with automatic refunds
471 lines (470 loc) • 19.3 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SolanaPaymentProvider = void 0;
const web3_js_1 = require("@solana/web3.js");
const pay_1 = require("@solana/pay");
const bignumber_js_1 = __importDefault(require("bignumber.js"));
const crypto_1 = require("crypto");
/**
* Solana Payment Provider for WS402
*
* Supports:
* - Native SOL payments
* - SPL Token payments
* - Solana Pay QR codes
* - On-chain payment verification
* - Automatic refunds
*/
class SolanaPaymentProvider {
constructor(config) {
this.connection = new web3_js_1.Connection(config.rpcEndpoint, 'confirmed');
this.merchantWallet = new web3_js_1.PublicKey(config.merchantWallet);
// Initialize keypair if private key provided
this.merchantKeypair = null;
if (config.merchantPrivateKey && config.merchantPrivateKey.length === 64) {
try {
this.merchantKeypair = web3_js_1.Keypair.fromSecretKey(Uint8Array.from(config.merchantPrivateKey));
this.log('Merchant keypair loaded - automatic refunds enabled');
}
catch (error) {
this.log('Warning: Failed to load merchant keypair', error.message);
}
}
this.config = {
rpcEndpoint: config.rpcEndpoint,
merchantWallet: config.merchantWallet,
network: config.network || 'mainnet-beta',
conversionRate: config.conversionRate || 1, // 1:1 by default
paymentTimeout: config.paymentTimeout || 300000, // 5 minutes
label: config.label || 'WS402 Payment',
message: config.message || 'Pay for WebSocket resource access',
memo: config.memo || 'WS402',
splToken: config.splToken,
autoRefund: config.autoRefund !== false && this.merchantKeypair !== null, // Enable by default if keypair available
};
this.pendingPayments = new Map();
}
/**
* Validate amount is positive
*/
validateAmount(amount) {
if (amount <= 0) {
throw new Error('Amount must be positive');
}
}
/**
* Log payment activity
*/
log(message, data) {
console.log(`[SolanaPaymentProvider] ${message}`, data || '');
}
/**
* Generate payment details with Solana Pay QR code
*/
generatePaymentDetails(amount) {
this.validateAmount(amount);
// Convert amount to SOL
const amountSOL = new bignumber_js_1.default(amount)
.dividedBy(this.config.conversionRate)
.dividedBy(web3_js_1.LAMPORTS_PER_SOL);
const reference = this.generateReference();
// Store pending payment
this.pendingPayments.set(reference, {
amount,
amountSOL,
timestamp: Date.now(),
recipient: this.merchantWallet,
});
// Create Solana Pay URL
const url = (0, pay_1.encodeURL)({
recipient: this.merchantWallet,
amount: amountSOL,
reference: new web3_js_1.PublicKey(reference),
label: this.config.label,
message: this.config.message,
memo: this.config.memo,
splToken: this.config.splToken ? new web3_js_1.PublicKey(this.config.splToken) : undefined,
});
return {
type: 'solana',
network: this.config.network,
recipient: this.merchantWallet.toBase58(),
amount: amount,
amountSOL: amountSOL.toString(),
currency: this.config.splToken ? 'SPL' : 'SOL',
splToken: this.config.splToken,
reference,
solanaPayURL: url.toString(),
qrCode: url.toString(), // Can be used with createQR() on client side
expiresAt: Date.now() + this.config.paymentTimeout,
instructions: {
step1: 'Scan QR code with Solana-compatible wallet',
step2: 'Or use Phantom, Solflare, or other Solana wallet',
step3: 'Approve transaction',
step4: 'Connection will be established automatically',
}
};
}
/**
* Verify Solana payment on-chain
*/
async verifyPayment(proof) {
try {
const { signature, reference } = proof;
if (!signature) {
return {
valid: false,
amount: 0,
reason: 'Missing transaction signature',
};
}
this.log('Verifying Solana payment', { signature, reference });
// Get pending payment info
const pending = this.pendingPayments.get(reference);
if (!pending) {
return {
valid: false,
amount: 0,
reason: 'Invalid or expired payment reference',
};
}
// Check if payment has expired
if (Date.now() - pending.timestamp > this.config.paymentTimeout) {
this.pendingPayments.delete(reference);
return {
valid: false,
amount: 0,
reason: 'Payment timeout expired',
};
}
// Fetch transaction from blockchain with retries
this.log('Fetching transaction from blockchain...', { signature });
let tx = null;
const maxRetries = 10;
const retryDelay = 2000; // 2 seconds
for (let i = 0; i < maxRetries; i++) {
try {
tx = await this.connection.getParsedTransaction(signature, {
maxSupportedTransactionVersion: 0,
commitment: 'confirmed',
});
if (tx) {
this.log('Transaction found on blockchain', { attempt: i + 1 });
break;
}
// Transaction not found yet, wait and retry
if (i < maxRetries - 1) {
this.log(`Transaction not found yet, retrying... (${i + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
catch (error) {
this.log('Error fetching transaction', { attempt: i + 1, error: error.message });
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
}
if (!tx) {
return {
valid: false,
amount: 0,
reason: 'Transaction not found on blockchain',
};
}
// Verify transaction was successful
if (tx.meta?.err) {
return {
valid: false,
amount: 0,
reason: `Transaction failed: ${JSON.stringify(tx.meta.err)}`,
};
}
// Verify payment to correct recipient
const verified = await this.verifyTransactionDetails(tx, pending.recipient, pending.amountSOL, reference);
if (!verified.valid) {
return verified;
}
// Clean up pending payment
this.pendingPayments.delete(reference);
this.log('Payment verified successfully', {
signature,
amount: pending.amount,
amountSOL: pending.amountSOL.toString(),
});
return {
valid: true,
amount: pending.amount,
};
}
catch (error) {
this.log('Payment verification error', error.message);
return {
valid: false,
amount: 0,
reason: `Verification error: ${error.message}`,
};
}
}
/**
* Issue refund via Solana transaction
*/
async issueRefund(proof, amount) {
try {
const { signature, senderWallet } = proof;
if (!senderWallet) {
throw new Error('Sender wallet address required for refund');
}
// Validate sender wallet address
let recipientPubkey;
try {
recipientPubkey = new web3_js_1.PublicKey(senderWallet);
}
catch (error) {
throw new Error(`Invalid sender wallet address: ${senderWallet}`);
}
this.log('Issuing Solana refund', {
amount,
recipient: senderWallet,
originalTx: signature,
});
// Convert refund amount to lamports
const refundSOL = new bignumber_js_1.default(amount)
.dividedBy(this.config.conversionRate)
.dividedBy(web3_js_1.LAMPORTS_PER_SOL);
const lamports = Math.floor(refundSOL.multipliedBy(web3_js_1.LAMPORTS_PER_SOL).toNumber());
// Check if amount is too small
if (lamports < 1) {
this.log('Refund amount too small, skipping', { lamports });
return;
}
this.log('Refund calculated', {
amount,
refundSOL: refundSOL.toString(),
lamports,
});
// Check if auto-refund is enabled and keypair is available
if (!this.config.autoRefund || !this.merchantKeypair) {
this.log('⚠️ Auto-refund disabled or keypair not available', {
autoRefund: this.config.autoRefund,
hasKeypair: this.merchantKeypair !== null,
});
this.log('Refund prepared but not sent', {
to: senderWallet,
amount: lamports,
memo: `WS402 Refund - Original TX: ${signature?.slice(0, 20)}...`,
});
return;
}
// Check merchant balance
const merchantBalance = await this.connection.getBalance(this.merchantWallet);
const minBalance = 5000; // Keep 5000 lamports (0.000005 SOL) for rent
if (merchantBalance < lamports + minBalance) {
throw new Error(`Insufficient merchant balance. Need ${lamports + minBalance} lamports, have ${merchantBalance} lamports`);
}
// Create refund transaction
const transaction = new web3_js_1.Transaction().add(web3_js_1.SystemProgram.transfer({
fromPubkey: this.merchantWallet,
toPubkey: recipientPubkey,
lamports,
}));
// Add memo if original signature exists
if (signature) {
const memoData = Buffer.from(`WS402 Refund: ${signature.slice(0, 20)}...`, 'utf-8');
// Note: For production, you might want to use SPL Memo program
// For now, we'll keep it simple
}
this.log('Sending refund transaction...', {
from: this.merchantWallet.toBase58(),
to: recipientPubkey.toBase58(),
lamports,
});
// Send transaction (don't use sendAndConfirmTransaction - it needs WebSocket)
const txSignature = await this.connection.sendTransaction(transaction, [this.merchantKeypair], {
skipPreflight: false,
preflightCommitment: 'confirmed',
});
this.log('📤 Refund transaction sent', { signature: txSignature });
// Manually confirm using polling (Alchemy doesn't support WebSocket subscriptions)
const startTime = Date.now();
const timeout = 30000; // 30 seconds
let confirmed = false;
while (!confirmed && (Date.now() - startTime) < timeout) {
try {
const status = await this.connection.getSignatureStatus(txSignature);
if (status?.value?.confirmationStatus === 'confirmed' ||
status?.value?.confirmationStatus === 'finalized') {
confirmed = true;
break;
}
if (status?.value?.err) {
throw new Error(`Transaction failed: ${JSON.stringify(status.value.err)}`);
}
// Wait 2 seconds before next check
await new Promise(resolve => setTimeout(resolve, 2000));
}
catch (error) {
// Continue trying
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
if (!confirmed) {
this.log('⚠️ Refund sent but confirmation timeout. Check explorer:', {
signature: txSignature,
explorer: `https://solscan.io/tx/${txSignature}${this.config.network !== 'mainnet-beta' ? '?cluster=' + this.config.network : ''}`,
});
}
else {
this.log('✅ Refund transaction confirmed', {
signature: txSignature,
amount: lamports,
amountSOL: refundSOL.toString(),
recipient: senderWallet,
explorer: `https://solscan.io/tx/${txSignature}${this.config.network !== 'mainnet-beta' ? '?cluster=' + this.config.network : ''}`,
});
}
}
catch (error) {
this.log('❌ Refund error', error.message);
// Don't throw error to prevent session cleanup from failing
// Log the error but allow the session to end gracefully
if (error.message.includes('Insufficient')) {
this.log('⚠️ Merchant wallet has insufficient balance for refund');
}
else if (error.message.includes('Invalid')) {
this.log('⚠️ Invalid wallet address for refund');
}
else {
this.log('⚠️ Refund failed, may need manual processing', {
error: error.message,
stack: error.stack,
});
}
}
}
/**
* Verify transaction details match expected payment
*/
async verifyTransactionDetails(tx, expectedRecipient, expectedAmount, expectedReference) {
// Find the transfer instruction
const instructions = tx.transaction.message.instructions;
let transferFound = false;
let transferAmount = new bignumber_js_1.default(0);
for (const instruction of instructions) {
if ('parsed' in instruction && instruction.program === 'system') {
const parsed = instruction.parsed;
if (parsed.type === 'transfer') {
const info = parsed.info;
// Check recipient matches
if (info.destination === expectedRecipient.toBase58()) {
transferAmount = new bignumber_js_1.default(info.lamports);
transferFound = true;
break;
}
}
}
}
if (!transferFound) {
return {
valid: false,
amount: 0,
reason: 'No valid transfer instruction found',
};
}
// Verify amount (allow small variance for fees)
const expectedLamports = expectedAmount.multipliedBy(web3_js_1.LAMPORTS_PER_SOL);
const variance = transferAmount.minus(expectedLamports).abs();
const allowedVariance = expectedLamports.multipliedBy(0.01); // 1% variance
if (variance.isGreaterThan(allowedVariance)) {
return {
valid: false,
amount: 0,
reason: `Amount mismatch. Expected: ${expectedLamports.toString()}, Received: ${transferAmount.toString()}`,
};
}
// Verify reference is in transaction (check account keys)
let referenceFound = false;
try {
const referencePubkey = new web3_js_1.PublicKey(expectedReference);
const accountKeys = tx.transaction.message.accountKeys.map(key => typeof key === 'string' ? key : key.pubkey.toBase58());
referenceFound = accountKeys.includes(referencePubkey.toBase58());
}
catch (e) {
// Invalid reference format
}
if (!referenceFound) {
return {
valid: false,
amount: 0,
reason: 'Payment reference not found in transaction',
};
}
return {
valid: true,
amount: transferAmount.dividedBy(web3_js_1.LAMPORTS_PER_SOL)
.multipliedBy(this.config.conversionRate)
.toNumber(),
};
}
/**
* Generate unique reference for payment tracking
*/
generateReference() {
// Generate a valid Solana public key as reference
const array = new Uint8Array(32);
// Use webcrypto for Node.js compatibility
if (typeof crypto_1.webcrypto !== 'undefined' && crypto_1.webcrypto.getRandomValues) {
crypto_1.webcrypto.getRandomValues(array);
}
else if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(array);
}
else {
// Fallback to random bytes
for (let i = 0; i < 32; i++) {
array[i] = Math.floor(Math.random() * 256);
}
}
return new web3_js_1.PublicKey(array).toBase58();
}
/**
* Get pending payment info
*/
getPendingPayment(reference) {
return this.pendingPayments.get(reference);
}
/**
* Clean up expired pending payments
*/
cleanupExpiredPayments() {
const now = Date.now();
let cleaned = 0;
for (const [reference, payment] of this.pendingPayments.entries()) {
if (now - payment.timestamp > this.config.paymentTimeout) {
this.pendingPayments.delete(reference);
cleaned++;
}
}
if (cleaned > 0) {
this.log(`Cleaned up ${cleaned} expired pending payments`);
}
return cleaned;
}
/**
* Get connection info
*/
getConnectionInfo() {
return {
rpcEndpoint: this.config.rpcEndpoint,
network: this.config.network,
merchantWallet: this.merchantWallet.toBase58(),
splToken: this.config.splToken,
autoRefundEnabled: this.config.autoRefund,
hasKeypair: this.merchantKeypair !== null,
};
}
}
exports.SolanaPaymentProvider = SolanaPaymentProvider;