UNPKG

@bsv/payment-express-middleware

Version:
234 lines (233 loc) 9.17 kB
import { Beef, PublicKey, Utils, createNonce, verifyNonce } from "@bsv/sdk"; //#region src/index.ts const PAYMENT_VERSION = "1.0"; const DEFAULT_MAX_PAYMENT_HEADER_BYTES = 64 * 1024; const DEFAULT_REPLAY_CAPACITY = 1e5; const MAX_NONCE_LENGTH = 512; var InMemoryPaymentReplayStore = class { maxEntries; claimed = /* @__PURE__ */ new Set(); constructor(maxEntries = DEFAULT_REPLAY_CAPACITY) { this.maxEntries = maxEntries; if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) throw new RangeError("Replay-store capacity must be a positive safe integer."); } claim(transactionId) { if (this.claimed.has(transactionId)) return false; if (this.claimed.size >= this.maxEntries) throw new Error("Payment replay store capacity exceeded."); this.claimed.add(transactionId); return true; } }; function isPositiveSafeInteger(value) { return Number.isSafeInteger(value) && value > 0; } function isCanonicalBase64(value) { if (value.length === 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return false; try { return Utils.toBase64(Utils.toArray(value, "base64")) === value; } catch { return false; } } function isCompressedPublicKey(value) { if (!/^(02|03)[0-9a-fA-F]{64}$/.test(value)) return false; try { return PublicKey.fromString(value).toString() === value.toLowerCase(); } catch { return false; } } function paymentHeader(req) { const value = req.headers["x-bsv-payment"]; if (value === void 0) return void 0; return typeof value === "string" ? value : null; } function parsePaymentHeader(raw, maxBytes) { if (Buffer.byteLength(raw, "utf8") > maxBytes) return void 0; let value; try { value = JSON.parse(raw); } catch { return; } if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0; const record = value; if (typeof record.derivationPrefix !== "string" || typeof record.derivationSuffix !== "string" || typeof record.transaction !== "string" || record.derivationPrefix.length > MAX_NONCE_LENGTH || record.derivationSuffix.length > MAX_NONCE_LENGTH || !isCanonicalBase64(record.derivationPrefix) || !isCanonicalBase64(record.derivationSuffix) || !isCanonicalBase64(record.transaction)) return; return { derivationPrefix: record.derivationPrefix, derivationSuffix: record.derivationSuffix, transaction: record.transaction }; } function parseAtomicPayment(payment, requiredSatoshis) { try { const transaction = Utils.toArray(payment.transaction, "base64"); const beef = Beef.fromBinary(transaction); const transactionId = beef.atomicTxid; if (typeof transactionId !== "string") return void 0; const satoshis = (beef.findTxid(transactionId)?.tx)?.outputs[0]?.satoshis; if (typeof satoshis !== "number" || satoshis < requiredSatoshis) return void 0; return { payment, transaction, transactionId, satoshis }; } catch { return; } } function sendError(res, status, code, description, details = {}) { res.status(status).json({ status: "error", code, ...details, description }); } function safeErrorContext(error) { return error instanceof Error ? { errorName: error.name } : { errorType: typeof error }; } function isPaymentLogger(value) { if (value === void 0) return true; if (value === null || typeof value !== "object") return false; const logger = value; return (logger.error === void 0 || typeof logger.error === "function") && (logger.warn === void 0 || typeof logger.warn === "function"); } async function issuePaymentChallenge(wallet, res, requestPrice, logger) { try { const derivationPrefix = await createNonce(wallet); res.status(402).set({ "x-bsv-payment-version": PAYMENT_VERSION, "x-bsv-payment-satoshis-required": String(requestPrice), "x-bsv-payment-derivation-prefix": derivationPrefix }).json({ status: "error", code: "ERR_PAYMENT_REQUIRED", satoshisRequired: requestPrice, description: "A BSV payment is required. Provide the X-BSV-Payment header." }); } catch (error) { logger?.error?.("Failed to create a payment challenge.", safeErrorContext(error)); sendError(res, 503, "ERR_PAYMENT_UNAVAILABLE", "Payment processing is temporarily unavailable."); } } /** * Creates middleware that enforces a BRC-29 wallet payment after BRC-103 auth. */ function createPaymentMiddleware(options) { if (options === null || typeof options !== "object") throw new TypeError("Payment middleware options are required."); const { calculateRequestPrice = () => 100, wallet, replayStore = new InMemoryPaymentReplayStore(), maxPaymentHeaderBytes = DEFAULT_MAX_PAYMENT_HEADER_BYTES, logger } = options; if (typeof calculateRequestPrice !== "function") throw new TypeError("The calculateRequestPrice option must be a function."); if (wallet === null || typeof wallet !== "object" || typeof wallet.internalizeAction !== "function") throw new TypeError("A valid wallet instance must be supplied to the payment middleware."); if (replayStore === null || typeof replayStore.claim !== "function") throw new TypeError("A replay store with an atomic claim method is required."); if (!Number.isSafeInteger(maxPaymentHeaderBytes) || maxPaymentHeaderBytes < 1) throw new RangeError("maxPaymentHeaderBytes must be a positive safe integer."); if (!isPaymentLogger(logger)) throw new TypeError("logger error and warn properties must be functions when provided."); return async (req, res, next) => { const paymentRequest = req; const identityKey = paymentRequest.auth?.identityKey; if (typeof identityKey !== "string" || !isCompressedPublicKey(identityKey)) { sendError(res, 500, "ERR_SERVER_MISCONFIGURED", "The payment middleware must run after successful Auth middleware."); return; } let requestPrice; try { requestPrice = await calculateRequestPrice(paymentRequest); } catch (error) { logger?.error?.("Payment pricing failed.", safeErrorContext(error)); sendError(res, 500, "ERR_PAYMENT_INTERNAL", "An internal error occurred while determining the payment required for this request."); return; } if (requestPrice === 0) { paymentRequest.payment = { satoshisPaid: 0, accepted: true, tx: "", txid: "" }; next(); return; } if (!isPositiveSafeInteger(requestPrice)) { logger?.error?.("Payment pricing returned an invalid value.", { requestPrice }); sendError(res, 500, "ERR_PAYMENT_INTERNAL", "The configured payment price is invalid."); return; } const rawPayment = paymentHeader(paymentRequest); if (rawPayment === void 0) { await issuePaymentChallenge(wallet, res, requestPrice, logger); return; } if (rawPayment === null) { sendError(res, 400, "ERR_MALFORMED_PAYMENT", "The X-BSV-Payment header is malformed."); return; } const payment = parsePaymentHeader(rawPayment, maxPaymentHeaderBytes); if (payment === void 0) { sendError(res, 400, "ERR_MALFORMED_PAYMENT", "The X-BSV-Payment header is malformed."); return; } let validPrefix = false; try { validPrefix = await verifyNonce(payment.derivationPrefix, wallet); } catch (error) { logger?.warn?.("Payment derivation-prefix verification failed.", safeErrorContext(error)); } if (!validPrefix) { sendError(res, 400, "ERR_INVALID_DERIVATION_PREFIX", "The payment derivation prefix is invalid."); return; } const parsed = parseAtomicPayment(payment, requestPrice); if (parsed === void 0) { sendError(res, 400, "ERR_INVALID_PAYMENT", "The payment transaction is invalid or does not cover the required amount."); return; } let claimed; try { const claimResult = await replayStore.claim(parsed.transactionId); if (typeof claimResult !== "boolean") throw new TypeError("The replay store returned an invalid claim result."); claimed = claimResult; } catch (error) { logger?.error?.("Payment replay claim failed.", safeErrorContext(error)); sendError(res, 503, "ERR_PAYMENT_UNAVAILABLE", "Payment processing is temporarily unavailable."); return; } if (!claimed) { sendError(res, 409, "ERR_PAYMENT_REPLAYED", "This payment was already used."); return; } try { const result = await wallet.internalizeAction({ tx: parsed.transaction, outputs: [{ paymentRemittance: { derivationPrefix: payment.derivationPrefix, derivationSuffix: payment.derivationSuffix, senderIdentityKey: identityKey }, outputIndex: 0, protocol: "wallet payment" }], description: "Payment for request" }); if (result.accepted !== true || result.isMerge === true) { sendError(res, 409, "ERR_PAYMENT_REPLAYED", "This payment was not newly accepted."); return; } paymentRequest.payment = { satoshisPaid: parsed.satoshis, accepted: true, tx: payment.transaction, txid: parsed.transactionId }; res.set({ "x-bsv-payment-satoshis-paid": String(parsed.satoshis) }); next(); } catch (error) { logger?.warn?.("Payment internalization failed.", safeErrorContext(error)); sendError(res, 400, "ERR_PAYMENT_FAILED", "The payment could not be accepted."); } }; } //#endregion export { InMemoryPaymentReplayStore, createPaymentMiddleware }; //# sourceMappingURL=index.mjs.map